@openmixer/plugin-qualify 0.20.3 → 0.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalogs/ca.json +244 -1
- package/catalogs/de.json +49 -0
- package/catalogs/es.json +49 -0
- package/catalogs/fr.json +49 -0
- package/catalogs/it.json +49 -0
- package/catalogs/pt.json +49 -0
- package/dist/compare.d.ts +12 -11
- package/dist/compare.js +71 -38
- package/dist/compare.js.map +1 -1
- package/dist/hostd-pin-files.d.ts +6 -2
- package/dist/hostd-pin-files.js +10 -6
- package/dist/hostd-pin-files.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin-db-cli.d.ts +28 -0
- package/dist/plugin-db-cli.js +43 -0
- package/dist/plugin-db-cli.js.map +1 -0
- package/dist/plugin-db-pin.d.ts +8 -0
- package/dist/plugin-db-pin.js +29 -0
- package/dist/plugin-db-pin.js.map +1 -0
- package/dist/qualify-sync.js +3 -2
- package/dist/qualify-sync.js.map +1 -1
- package/dist/record-figures.d.ts +52 -0
- package/dist/record-figures.js +58 -0
- package/dist/record-figures.js.map +1 -0
- package/dist/refused-stderr-kept.d.ts +12 -0
- package/dist/refused-stderr-kept.js +67 -0
- package/dist/refused-stderr-kept.js.map +1 -0
- package/dist/text-catalog.d.ts +41 -0
- package/dist/text-catalog.js +97 -0
- package/dist/text-catalog.js.map +1 -0
- package/dist/text-codes.d.ts +29 -0
- package/dist/text-codes.generated.d.ts +88 -0
- package/dist/text-codes.generated.js +96 -0
- package/dist/text-codes.generated.js.map +1 -0
- package/dist/text-codes.js +19 -0
- package/dist/text-codes.js.map +1 -0
- package/package.json +3 -3
- package/schema/codes.json +372 -1
- package/src/compare.ts +85 -45
- package/src/hostd-pin-files.ts +13 -8
- package/src/index.ts +4 -0
- package/src/plugin-db-cli.ts +78 -0
- package/src/plugin-db-pin.ts +33 -0
- package/src/qualify-sync.ts +3 -2
- package/src/record-figures.ts +114 -0
- package/src/refused-stderr-kept.ts +84 -0
- package/src/text-catalog.ts +116 -0
- package/src/text-codes.generated.ts +97 -0
- package/src/text-codes.ts +35 -0
- package/tools/catalogs-gate.sh +22 -0
- package/tools/compare-host.mjs +1 -1
- package/tools/compare-run.mjs +10 -6
- package/tools/gen-text-codes.d.mts +8 -0
- package/tools/gen-text-codes.mjs +77 -0
package/src/compare.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
|
|
18
|
+
import { TEXT_CODE, type ReasonText } from "./text-codes.js";
|
|
19
|
+
import { reasonText } from "./text-catalog.js";
|
|
20
|
+
|
|
18
21
|
export const COMPARISON_RECORD_SCHEMA = "plugin-qualify.comparison-record/1";
|
|
19
22
|
export const COMPARISON_SUMMARY_SCHEMA = "plugin-qualify.comparison-summary/1";
|
|
20
23
|
export const COMPARISON_RECORD_SUFFIX = ".comparison.json";
|
|
@@ -33,7 +36,7 @@ export interface HostTraits {
|
|
|
33
36
|
readonly drive: "protocol" | "process";
|
|
34
37
|
readonly formats: readonly ("lv2" | "clap")[];
|
|
35
38
|
/** Why the plugin latency it reports is or is not observable. */
|
|
36
|
-
readonly latency: { readonly observable: boolean; readonly how:
|
|
39
|
+
readonly latency: { readonly observable: boolean; readonly how: ReasonText };
|
|
37
40
|
readonly rt: string;
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -42,28 +45,28 @@ export const HOST_TRAITS: Readonly<Record<CompareHost, HostTraits>> = {
|
|
|
42
45
|
program: "omx-clap-host",
|
|
43
46
|
drive: "protocol",
|
|
44
47
|
formats: ["lv2", "clap"],
|
|
45
|
-
latency: { observable: true, how:
|
|
48
|
+
latency: { observable: true, how: { code: TEXT_CODE.compareLatencyPortObservable } },
|
|
46
49
|
rt: "its JACK process callback, counted by rt_watch.so",
|
|
47
50
|
},
|
|
48
51
|
"plugin-hostd": {
|
|
49
52
|
program: "plugin-hostd",
|
|
50
53
|
drive: "protocol",
|
|
51
54
|
formats: ["lv2", "clap"],
|
|
52
|
-
latency: { observable: true, how:
|
|
55
|
+
latency: { observable: true, how: { code: TEXT_CODE.compareLatencyHostdPortObservable } },
|
|
53
56
|
rt: "the JACK process callback of every process it starts, counted by rt_watch.so",
|
|
54
57
|
},
|
|
55
58
|
"mod-host": {
|
|
56
59
|
program: "mod-host",
|
|
57
60
|
drive: "protocol",
|
|
58
61
|
formats: ["lv2"],
|
|
59
|
-
latency: { observable: false, how:
|
|
62
|
+
latency: { observable: false, how: { code: TEXT_CODE.compareLatencyNotObservableModHost } },
|
|
60
63
|
rt: "its JACK process callback, counted by rt_watch.so",
|
|
61
64
|
},
|
|
62
65
|
jalv: {
|
|
63
66
|
program: "jalv",
|
|
64
67
|
drive: "process",
|
|
65
68
|
formats: ["lv2"],
|
|
66
|
-
latency: { observable: true, how:
|
|
69
|
+
latency: { observable: true, how: { code: TEXT_CODE.compareLatencyPortObservable } },
|
|
67
70
|
rt: "its JACK process callback, counted by rt_watch.so",
|
|
68
71
|
},
|
|
69
72
|
};
|
|
@@ -234,15 +237,15 @@ export interface HostPresence {
|
|
|
234
237
|
readonly installed: boolean;
|
|
235
238
|
readonly version: string | null;
|
|
236
239
|
/** Why it is skipped, when it is. */
|
|
237
|
-
readonly reason?:
|
|
240
|
+
readonly reason?: ReasonText;
|
|
238
241
|
}
|
|
239
242
|
|
|
240
243
|
/** The reference hosts a publishable summary lacks, by name, with the reason each is missing. */
|
|
241
|
-
export function missingReferences(present: readonly HostPresence[]): readonly { host: CompareHost; reason:
|
|
244
|
+
export function missingReferences(present: readonly HostPresence[]): readonly { host: CompareHost; reason: ReasonText }[] {
|
|
242
245
|
return REFERENCE_HOSTS.flatMap((h) => {
|
|
243
246
|
const p = present.find((x) => x.host === h);
|
|
244
|
-
if (p === undefined) return [{ host: h, reason:
|
|
245
|
-
if (!p.installed) return [{ host: h, reason: p.reason ??
|
|
247
|
+
if (p === undefined) return [{ host: h, reason: { code: TEXT_CODE.compareHostNotAsked } }];
|
|
248
|
+
if (!p.installed) return [{ host: h, reason: p.reason ?? { code: TEXT_CODE.compareHostNotInstalled, args: { program: HOST_TRAITS[h].program } } }];
|
|
246
249
|
return [];
|
|
247
250
|
});
|
|
248
251
|
}
|
|
@@ -256,7 +259,7 @@ export function presenceFromRecords(records: readonly ComparisonRecord[]): HostP
|
|
|
256
259
|
if (ran !== undefined) return [{ host: h, installed: true, version: ran.version }];
|
|
257
260
|
if (rows.every((x) => x.status === "not-applicable")) return [{ host: h, installed: true, version: rows[0]?.version ?? null }];
|
|
258
261
|
const skipped = rows.find((x) => x.status === "skipped");
|
|
259
|
-
return [{ host: h, installed: false, version: null, reason: skipped?.reason ??
|
|
262
|
+
return [{ host: h, installed: false, version: null, reason: skipped?.reason ?? { code: TEXT_CODE.compareHostDidNotRun, args: { program: HOST_TRAITS[h].program } } }];
|
|
260
263
|
});
|
|
261
264
|
}
|
|
262
265
|
|
|
@@ -308,7 +311,7 @@ export type FigureResult = "measured" | "not-observable" | "not-run";
|
|
|
308
311
|
|
|
309
312
|
export interface Check {
|
|
310
313
|
readonly result: CheckResult;
|
|
311
|
-
readonly detail?:
|
|
314
|
+
readonly detail?: ReasonText;
|
|
312
315
|
}
|
|
313
316
|
export interface OutputCheck extends Check {
|
|
314
317
|
readonly peak?: number;
|
|
@@ -335,12 +338,12 @@ export interface CpuFigure {
|
|
|
335
338
|
readonly result: FigureResult;
|
|
336
339
|
/** utime + stime of the host's processes over the soak, in percent of one core. */
|
|
337
340
|
readonly percent?: number;
|
|
338
|
-
readonly detail?:
|
|
341
|
+
readonly detail?: ReasonText;
|
|
339
342
|
}
|
|
340
343
|
export interface LatencyFigure {
|
|
341
344
|
readonly result: FigureResult;
|
|
342
345
|
readonly frames?: number;
|
|
343
|
-
readonly detail?:
|
|
346
|
+
readonly detail?: ReasonText;
|
|
344
347
|
}
|
|
345
348
|
|
|
346
349
|
export interface HostChecks {
|
|
@@ -358,7 +361,7 @@ export interface HostRow {
|
|
|
358
361
|
readonly host: CompareHost;
|
|
359
362
|
readonly version: string | null;
|
|
360
363
|
readonly status: "ran" | "skipped" | "not-applicable";
|
|
361
|
-
readonly reason?:
|
|
364
|
+
readonly reason?: ReasonText;
|
|
362
365
|
readonly checks?: HostChecks;
|
|
363
366
|
/** The step the host died at and how, when it did. */
|
|
364
367
|
readonly crash?: { readonly step: string; readonly signal: string };
|
|
@@ -410,11 +413,11 @@ const audible = (p: number | null | undefined): boolean => typeof p === "number"
|
|
|
410
413
|
|
|
411
414
|
/** The real-time verdict from the reports of the host's processes. */
|
|
412
415
|
export function rtCheckOf(reports: readonly RtReport[] | undefined): RtCheck {
|
|
413
|
-
if (reports === undefined || reports.length === 0) return { result: "not-observable", detail:
|
|
416
|
+
if (reports === undefined || reports.length === 0) return { result: "not-observable", detail: { code: TEXT_CODE.compareRtNotReported } };
|
|
414
417
|
const watched = reports.filter((r) => r.callbacks > 0);
|
|
415
418
|
const armed = watched.reduce((s, r) => s + r.armedCycles, 0);
|
|
416
|
-
if (watched.length === 0) return { result: "not-observable", detail:
|
|
417
|
-
if (armed === 0) return { result: "not-observable", detail:
|
|
419
|
+
if (watched.length === 0) return { result: "not-observable", detail: { code: TEXT_CODE.compareRtNoCallback } };
|
|
420
|
+
if (armed === 0) return { result: "not-observable", detail: { code: TEXT_CODE.compareRtNoWarmUp } };
|
|
418
421
|
const allocations = watched.reduce((s, r) => s + r.allocations, 0);
|
|
419
422
|
const locks = watched.reduce((s, r) => s + r.locks, 0);
|
|
420
423
|
const syscalls = watched.reduce((s, r) => s + r.syscalls, 0);
|
|
@@ -429,65 +432,92 @@ export function rtCheckOf(reports: readonly RtReport[] | undefined): RtCheck {
|
|
|
429
432
|
syscalls,
|
|
430
433
|
cycles: armed,
|
|
431
434
|
...(kinds.length ? { kinds } : {}),
|
|
432
|
-
...(clean
|
|
435
|
+
...(clean
|
|
436
|
+
? trylocks > 0
|
|
437
|
+
? { detail: { code: TEXT_CODE.compareRtTrylocksOnly, args: { trylocks, cycles: armed } } }
|
|
438
|
+
: {}
|
|
439
|
+
: { detail: { code: TEXT_CODE.compareRtUnsafe, args: { cycles: armed, allocations, locks, syscalls } } }),
|
|
433
440
|
};
|
|
434
441
|
}
|
|
435
442
|
|
|
436
|
-
const
|
|
443
|
+
const NOT_RUNNING: ReasonText = { code: TEXT_CODE.compareNotRunning };
|
|
444
|
+
const NOT_RUN = (why: ReasonText): Check => ({ result: "not-run", detail: why });
|
|
437
445
|
|
|
438
446
|
/** One host's observation of one plugin as its row. */
|
|
439
447
|
export function hostRowOf(host: CompareHost, version: string | null, obs: HostObservation, conditions: { cycles: number; soakS: number }): HostRow {
|
|
440
448
|
const base = { host, version };
|
|
441
|
-
if (!obs.installed) return { ...base, status: "skipped", reason: obs.problem ??
|
|
442
|
-
if (obs.started === false) return { ...base, status: "skipped", reason: obs.problem ??
|
|
449
|
+
if (!obs.installed) return { ...base, status: "skipped", reason: obs.problem ?? { code: TEXT_CODE.compareHostNotInstalled, args: { program: HOST_TRAITS[host].program } } };
|
|
450
|
+
if (obs.started === false) return { ...base, status: "skipped", reason: obs.problem ?? { code: TEXT_CODE.compareHostDidNotStart, args: { program: HOST_TRAITS[host].program } } };
|
|
443
451
|
const load = obs.load;
|
|
444
452
|
const loaded = load?.ok === true;
|
|
445
453
|
const loads: Check = loaded
|
|
446
454
|
? { result: "pass" }
|
|
447
|
-
: {
|
|
455
|
+
: {
|
|
456
|
+
result: "fail",
|
|
457
|
+
detail: load?.died
|
|
458
|
+
? { code: TEXT_CODE.compareLoadHostEnded, args: { died: load.died } }
|
|
459
|
+
: load?.resp !== undefined
|
|
460
|
+
? { code: TEXT_CODE.compareLoadResp, args: { resp: load.resp } }
|
|
461
|
+
: (load?.detail ?? { code: TEXT_CODE.compareLoadTimeout }),
|
|
462
|
+
};
|
|
448
463
|
const ports = obs.ports;
|
|
449
464
|
const anyPort = ports !== undefined && ports.audioIn + ports.audioOut + ports.midiIn > 0;
|
|
450
|
-
const instantiates: Check = !loaded
|
|
465
|
+
const instantiates: Check = !loaded
|
|
466
|
+
? NOT_RUN({ code: TEXT_CODE.compareNotLoaded })
|
|
467
|
+
: anyPort
|
|
468
|
+
? { result: "pass" }
|
|
469
|
+
: { result: "fail", detail: { code: TEXT_CODE.compareNoPort } };
|
|
451
470
|
const running = loaded && anyPort;
|
|
452
471
|
|
|
453
472
|
let output: OutputCheck;
|
|
454
|
-
if (!running) output = { ...NOT_RUN(
|
|
455
|
-
else if (ports.audioOut === 0) output = { result: "not-observable", detail:
|
|
473
|
+
if (!running) output = { ...NOT_RUN(NOT_RUNNING) };
|
|
474
|
+
else if (ports.audioOut === 0) output = { result: "not-observable", detail: { code: TEXT_CODE.compareNoAudioOutput } };
|
|
456
475
|
else if (obs.peakProblem !== undefined) output = { result: "not-observable", detail: obs.peakProblem };
|
|
457
|
-
else if ((obs.nonFinite ?? 0) > 0)
|
|
476
|
+
else if ((obs.nonFinite ?? 0) > 0)
|
|
477
|
+
output = { result: "fail", detail: { code: TEXT_CODE.compareOutputNotFinite, args: { count: obs.nonFinite ?? 0 } }, peak: obs.peak ?? 0, stimulus: obs.stimulus ?? "none" };
|
|
458
478
|
else if (audible(obs.peak)) output = { result: "pass", peak: obs.peak as number, stimulus: obs.stimulus ?? "none" };
|
|
459
|
-
else if (obs.stimulus === "none" || obs.stimulus === undefined)
|
|
460
|
-
|
|
479
|
+
else if (obs.stimulus === "none" || obs.stimulus === undefined)
|
|
480
|
+
output = { result: "not-observable", detail: { code: TEXT_CODE.compareNoStimulusSilent }, peak: obs.peak ?? 0, stimulus: "none" };
|
|
481
|
+
else output = { result: "fail", detail: { code: TEXT_CODE.compareSilentUnderStimulus, args: { stimulus: obs.stimulus } }, peak: obs.peak ?? 0, stimulus: obs.stimulus };
|
|
461
482
|
|
|
462
483
|
const c = obs.cycles;
|
|
463
484
|
const cycles: CyclesCheck =
|
|
464
485
|
c === undefined
|
|
465
|
-
? { result: "not-run", done: 0, of: conditions.cycles, detail:
|
|
486
|
+
? { result: "not-run", done: 0, of: conditions.cycles, detail: { code: TEXT_CODE.compareCyclesNotRun } }
|
|
466
487
|
: c.done === c.of
|
|
467
488
|
? { result: "pass", done: c.done, of: c.of }
|
|
468
|
-
: {
|
|
489
|
+
: {
|
|
490
|
+
result: "fail",
|
|
491
|
+
done: c.done,
|
|
492
|
+
of: c.of,
|
|
493
|
+
detail: c.died
|
|
494
|
+
? { code: TEXT_CODE.compareCycleHostEnded, args: { cycle: c.done + 1, died: c.died } }
|
|
495
|
+
: c.failedAt
|
|
496
|
+
? { code: TEXT_CODE.compareCycleFailedAt, args: { cycle: c.done + 1, step: c.failedAt } }
|
|
497
|
+
: { code: TEXT_CODE.compareCycleFailed, args: { cycle: c.done + 1 } },
|
|
498
|
+
};
|
|
469
499
|
|
|
470
500
|
const s = obs.soak;
|
|
471
501
|
const soak: SoakCheck = !running || s === undefined
|
|
472
|
-
? { result: "not-run", seconds: 0, detail:
|
|
502
|
+
? { result: "not-run", seconds: 0, detail: NOT_RUNNING }
|
|
473
503
|
: s.alive
|
|
474
504
|
? { result: "pass", seconds: s.seconds, ...(typeof s.peakAtEnd === "number" ? { peakAtEnd: s.peakAtEnd } : {}) }
|
|
475
|
-
: { result: "fail", seconds: s.seconds, detail:
|
|
505
|
+
: { result: "fail", seconds: s.seconds, detail: s.died ? { code: TEXT_CODE.compareSoakHostEnded, args: { died: s.died } } : { code: TEXT_CODE.compareSoakEnded } };
|
|
476
506
|
|
|
477
|
-
const rtSafe: RtCheck = !running ? { result: "not-run", detail:
|
|
507
|
+
const rtSafe: RtCheck = !running ? { result: "not-run", detail: NOT_RUNNING } : rtCheckOf(obs.rt);
|
|
478
508
|
const cpu: CpuFigure = !running || obs.cpu === undefined
|
|
479
|
-
? { result: "not-run", detail:
|
|
509
|
+
? { result: "not-run", detail: NOT_RUNNING }
|
|
480
510
|
: "percent" in obs.cpu
|
|
481
511
|
? { result: "measured", percent: Math.round(obs.cpu.percent * 100) / 100 }
|
|
482
512
|
: { result: "not-observable", detail: obs.cpu.problem };
|
|
483
513
|
const traits = HOST_TRAITS[host];
|
|
484
514
|
const latency: LatencyFigure = !running
|
|
485
|
-
? { result: "not-run", detail:
|
|
515
|
+
? { result: "not-run", detail: NOT_RUNNING }
|
|
486
516
|
: !traits.latency.observable
|
|
487
517
|
? { result: "not-observable", detail: traits.latency.how }
|
|
488
518
|
: typeof obs.latencyFrames === "number"
|
|
489
519
|
? { result: "measured", frames: obs.latencyFrames }
|
|
490
|
-
: { result: "not-observable", detail:
|
|
520
|
+
: { result: "not-observable", detail: { code: TEXT_CODE.compareLatencyUnreadable } };
|
|
491
521
|
|
|
492
522
|
const crashed = obs.died !== undefined;
|
|
493
523
|
const runsClean = !crashed && loaded && running && cycles.result === "pass" && soak.result === "pass" && output.result !== "fail";
|
|
@@ -542,7 +572,7 @@ export function comparisonRecordOf(
|
|
|
542
572
|
|
|
543
573
|
/** The row of a host that does not take this plugin's format. */
|
|
544
574
|
export function notApplicableRow(host: CompareHost, version: string | null, format: string): HostRow {
|
|
545
|
-
return { host, version, status: "not-applicable", reason:
|
|
575
|
+
return { host, version, status: "not-applicable", reason: { code: TEXT_CODE.compareFormatNotTaken, args: { program: HOST_TRAITS[host].program, format: format.toUpperCase() } } };
|
|
546
576
|
}
|
|
547
577
|
|
|
548
578
|
/** Problems with a record's shape, empty when it is one: the records repository's check holds the same rules. */
|
|
@@ -567,7 +597,7 @@ export interface HostTotals {
|
|
|
567
597
|
readonly host: CompareHost;
|
|
568
598
|
readonly version: string | null;
|
|
569
599
|
readonly status: "ran" | "skipped";
|
|
570
|
-
readonly reason?:
|
|
600
|
+
readonly reason?: ReasonText;
|
|
571
601
|
/** Plugins of a format the host takes. */
|
|
572
602
|
readonly plugins: number;
|
|
573
603
|
readonly loads: number;
|
|
@@ -597,7 +627,7 @@ export interface ComparisonSummary {
|
|
|
597
627
|
readonly set: string | null;
|
|
598
628
|
readonly measured: string | null;
|
|
599
629
|
readonly publishable: boolean;
|
|
600
|
-
readonly missing: readonly { host: CompareHost; reason:
|
|
630
|
+
readonly missing: readonly { host: CompareHost; reason: ReasonText }[];
|
|
601
631
|
readonly conditions: ComparisonConditions | null;
|
|
602
632
|
readonly records: number;
|
|
603
633
|
/** Plugins every host that ran takes and ran: the like-for-like set the headline counts. */
|
|
@@ -636,7 +666,7 @@ export function summarise(
|
|
|
636
666
|
host: h,
|
|
637
667
|
version,
|
|
638
668
|
status: skipped ? "skipped" : "ran",
|
|
639
|
-
...(skipped ? { reason: p?.reason ??
|
|
669
|
+
...(skipped ? { reason: p?.reason ?? { code: TEXT_CODE.compareHostNotInstalled, args: { program: HOST_TRAITS[h].program } } } : {}),
|
|
640
670
|
plugins: rows.length,
|
|
641
671
|
loads: count((r) => r.checks?.loads.result === "pass"),
|
|
642
672
|
instantiates: count((r) => r.checks?.instantiates.result === "pass"),
|
|
@@ -681,19 +711,29 @@ export function summarise(
|
|
|
681
711
|
};
|
|
682
712
|
}
|
|
683
713
|
|
|
684
|
-
/** The text table of a summary, for the terminal. */
|
|
685
|
-
export function summaryTable(s: ComparisonSummary): string {
|
|
714
|
+
/** The text table of a summary, for the terminal, in `lang` (the catalog's sentence, else the English source). */
|
|
715
|
+
export function summaryTable(s: ComparisonSummary, lang?: string): string {
|
|
686
716
|
const lines = [`host version plugins loads clean rt-safe/observed crashes cpu%`];
|
|
687
717
|
for (const t of s.hosts) {
|
|
688
718
|
if (t.status === "skipped") {
|
|
689
|
-
lines.push(`${t.host.padEnd(15)}
|
|
719
|
+
lines.push(`${t.host.padEnd(15)} ${reasonText({ code: TEXT_CODE.compareSkippedReason, args: { reason: t.reason === undefined ? "" : reasonText(t.reason, lang) } }, lang)}`);
|
|
690
720
|
continue;
|
|
691
721
|
}
|
|
692
722
|
lines.push(
|
|
693
723
|
`${t.host.padEnd(15)} ${(t.version ?? "?").padEnd(27)} ${String(t.plugins).padStart(7)} ${String(t.loads).padStart(5)} ${String(t.runsClean).padStart(5)} ${`${String(t.rtSafe)}/${String(t.rtObserved)}`.padStart(16)} ${String(t.crashes).padStart(7)} ${t.cpuMedianPercent === null ? "-" : String(t.cpuMedianPercent)}`,
|
|
694
724
|
);
|
|
695
725
|
}
|
|
696
|
-
for (const [h, list] of Object.entries(s.beats))
|
|
697
|
-
|
|
726
|
+
for (const [h, list] of Object.entries(s.beats)) {
|
|
727
|
+
if (!list.length) continue;
|
|
728
|
+
const detail = list.map((b) => reasonText({ code: TEXT_CODE.compareBeatDescription, args: { id: b.id, on: b.on, over: b.over.join(",") } }, lang)).join("; ");
|
|
729
|
+
lines.push(reasonText({ code: TEXT_CODE.compareDoesBetterOn, args: { host: h, count: list.length, detail } }, lang));
|
|
730
|
+
}
|
|
731
|
+
if (!s.publishable) {
|
|
732
|
+
lines.push(
|
|
733
|
+
s.missing.length
|
|
734
|
+
? reasonText({ code: TEXT_CODE.compareNotPublishableWithMissing, args: { missing: s.missing.map((m) => `${m.host}: ${reasonText(m.reason, lang)}`).join("; ") } }, lang)
|
|
735
|
+
: reasonText({ code: TEXT_CODE.compareNotPublishableNoFlag }, lang),
|
|
736
|
+
);
|
|
737
|
+
}
|
|
698
738
|
return `${lines.join("\n")}\n`;
|
|
699
739
|
}
|
package/src/hostd-pin-files.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
+
import { TEXT_CODE, type ReasonText } from "./text-codes.js";
|
|
16
|
+
|
|
15
17
|
/** The daemon binary, else `plugin-hostd` on the PATH. */
|
|
16
18
|
export const HOSTD_ENV = "PLUGIN_HOSTD";
|
|
17
19
|
|
|
@@ -41,9 +43,12 @@ export const SPAWNED_PIN_FILES: PinFilesRun = (uri, lv2Dirs, env) => {
|
|
|
41
43
|
|
|
42
44
|
export type PinFiles =
|
|
43
45
|
| { readonly ok: true; readonly root: string; readonly files: readonly { readonly path: string; readonly sha256: string }[] }
|
|
44
|
-
| { readonly ok: false; readonly detail:
|
|
46
|
+
| { readonly ok: false; readonly detail: ReasonText };
|
|
45
47
|
|
|
46
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The files plugin-hostd collects for `uri`, each hashed; why not, when it cannot say: one of this package's codes
|
|
50
|
+
* (`reasonText` renders it), or the daemon's own last line as it wrote it.
|
|
51
|
+
*/
|
|
47
52
|
export function hostdPinFiles(
|
|
48
53
|
uri: string,
|
|
49
54
|
lv2Dirs: readonly string[],
|
|
@@ -52,11 +57,11 @@ export function hostdPinFiles(
|
|
|
52
57
|
read: (path: string) => Buffer = (p) => readFileSync(p),
|
|
53
58
|
): PinFiles {
|
|
54
59
|
const r = run(uri, lv2Dirs, env);
|
|
55
|
-
if (r.missing) return { ok: false, detail:
|
|
60
|
+
if (r.missing) return { ok: false, detail: { code: TEXT_CODE.pinHostdNotInstalled } };
|
|
56
61
|
if (r.status !== 0) {
|
|
57
|
-
const why = r.stdout.startsWith("Usage:")
|
|
58
|
-
?
|
|
59
|
-
: r.stderr.trim().split("\n").pop() ||
|
|
62
|
+
const why: ReasonText = r.stdout.startsWith("Usage:")
|
|
63
|
+
? { code: TEXT_CODE.pinHostdNoPinFiles }
|
|
64
|
+
: r.stderr.trim().split("\n").pop() || { code: TEXT_CODE.pinHostdExited, args: { status: String(r.status) } };
|
|
60
65
|
return { ok: false, detail: why };
|
|
61
66
|
}
|
|
62
67
|
let root: string | undefined;
|
|
@@ -65,13 +70,13 @@ export function hostdPinFiles(
|
|
|
65
70
|
if (line.startsWith("root ")) root = line.slice(5);
|
|
66
71
|
else if (line.startsWith("file ")) names.push(line.slice(5));
|
|
67
72
|
}
|
|
68
|
-
if (root === undefined || names.length === 0) return { ok: false, detail:
|
|
73
|
+
if (root === undefined || names.length === 0) return { ok: false, detail: { code: TEXT_CODE.pinHostdNoRootOrFile } };
|
|
69
74
|
try {
|
|
70
75
|
// a name is the word of a pin, percent-encoded when the file's name needs it (Surge XT's libSurge%20XT.so): the pin
|
|
71
76
|
// keeps it as written, the file is the name it decodes to, as the daemon opens it
|
|
72
77
|
const files = names.map((path) => ({ path, sha256: createHash("sha256").update(read(join(root!, decodeURIComponent(path)))).digest("hex") }));
|
|
73
78
|
return { ok: true, root, files: files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
|
|
74
79
|
} catch (e) {
|
|
75
|
-
return { ok: false, detail:
|
|
80
|
+
return { ok: false, detail: { code: TEXT_CODE.pinFileUnreadable, args: { error: e instanceof Error ? e.message : String(e) } } };
|
|
76
81
|
}
|
|
77
82
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ 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 "./record-figures.js";
|
|
27
28
|
export * from "./judgement-line.js";
|
|
28
29
|
export * from "./judged-layer.js";
|
|
29
30
|
export * from "./probe-runner.js";
|
|
@@ -42,3 +43,6 @@ export * from "./history-file.js";
|
|
|
42
43
|
export * from "./history-adjust.js";
|
|
43
44
|
export * from "./adjust-cli.js";
|
|
44
45
|
export * from "./compare.js";
|
|
46
|
+
export * from "./text-codes.js";
|
|
47
|
+
export * from "./text-catalog.js";
|
|
48
|
+
export * from "./refused-stderr-kept.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* plugin-db is the one writer's call into plugin-db's own store, as plugin-db's records documentation describes it:
|
|
5
|
+
* `plugin-db pin <address> --files <sha256> [--layout <scheme>:<sha256>]` writes the pinned layer, `plugin-db put
|
|
6
|
+
* <address> judged --writer plugin-qualify` writes the judged layer plugin-qualify is the one writer of, its body on
|
|
7
|
+
* stdin. `--system` names the service's store (plugin-db's own default otherwise, for an interactive caller). Nothing
|
|
8
|
+
* here computes a digest or a body; `plugin-db-pin.ts` and `judged-layer.ts` do.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
import type { JudgedBody } from "./judged-layer.js";
|
|
13
|
+
import type { LayerPin } from "./judged-layer.js";
|
|
14
|
+
|
|
15
|
+
/** The binary, else `plugin-db` on the PATH. */
|
|
16
|
+
export const PLUGIN_DB_ENV = "PLUGIN_DB";
|
|
17
|
+
|
|
18
|
+
export interface PluginDbRunResult {
|
|
19
|
+
readonly status: number | null;
|
|
20
|
+
readonly stdout: string;
|
|
21
|
+
readonly stderr: string;
|
|
22
|
+
/** plugin-db is not installed. */
|
|
23
|
+
readonly missing?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One run of `plugin-db <args...>`, the body on stdin when one is given. */
|
|
27
|
+
export type PluginDbRun = (args: readonly string[], input: string | undefined, env: Readonly<Record<string, string | undefined>>) => PluginDbRunResult;
|
|
28
|
+
|
|
29
|
+
export const SPAWNED_PLUGIN_DB: PluginDbRun = (args, input, env) => {
|
|
30
|
+
const r = spawnSync(env[PLUGIN_DB_ENV] ?? "plugin-db", args, { encoding: "utf8", timeout: 30000, env: { ...env }, ...(input === undefined ? {} : { input }) });
|
|
31
|
+
const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
32
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", ...(missing ? { missing } : {}) };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface PluginDbCallOptions {
|
|
36
|
+
/** The service's store (`--system`), plugin-db's own default otherwise. */
|
|
37
|
+
readonly system: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PluginDbCallResult = { readonly ok: true } | { readonly ok: false; readonly detail: string };
|
|
41
|
+
|
|
42
|
+
function systemFlag(args: readonly string[], opts: PluginDbCallOptions): string[] {
|
|
43
|
+
return opts.system ? [...args, "--system"] : [...args];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function refusal(r: PluginDbRunResult, verb: string): PluginDbCallResult {
|
|
47
|
+
if (r.missing) return { ok: false, detail: `plugin-db is not installed: the store is plugin-db's to ${verb}` };
|
|
48
|
+
const detail = r.stderr.trim().split("\n").pop();
|
|
49
|
+
return { ok: false, detail: detail || `plugin-db ${verb} exited ${String(r.status)}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `plugin-db pin <address> --files <sha256> [--layout <scheme>:<sha256>]`: the files of a plugin's bytes, pinned. */
|
|
53
|
+
export function pluginDbPin(
|
|
54
|
+
address: string,
|
|
55
|
+
pin: LayerPin,
|
|
56
|
+
opts: PluginDbCallOptions,
|
|
57
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
58
|
+
run: PluginDbRun = SPAWNED_PLUGIN_DB,
|
|
59
|
+
): PluginDbCallResult {
|
|
60
|
+
const args = systemFlag(["pin", address, "--files", pin.files, ...(pin.layout === null ? [] : ["--layout", pin.layout])], opts);
|
|
61
|
+
const r = run(args, undefined, env);
|
|
62
|
+
if (r.status !== 0 || r.missing) return refusal(r, "pin");
|
|
63
|
+
return { ok: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `plugin-db put <address> judged --writer plugin-qualify`, the judged layer's body on stdin. */
|
|
67
|
+
export function pluginDbPutJudged(
|
|
68
|
+
address: string,
|
|
69
|
+
body: JudgedBody,
|
|
70
|
+
opts: PluginDbCallOptions,
|
|
71
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
72
|
+
run: PluginDbRun = SPAWNED_PLUGIN_DB,
|
|
73
|
+
): PluginDbCallResult {
|
|
74
|
+
const args = systemFlag(["put", address, "judged", "--writer", "plugin-qualify"], opts);
|
|
75
|
+
const r = run(args, JSON.stringify(body), env);
|
|
76
|
+
if (r.status !== 0 || r.missing) return refusal(r, "put");
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The pin plugin-db holds for an address: `files`, the combined digest of every file a plugin's bytes are
|
|
5
|
+
* (`omx-files/1`, plugin-db's `plugin_db_tables.h`: the SHA-256 of the line `omx-files/1`, then one line
|
|
6
|
+
* `<path>\t<sha256>` per file in byte order of the paths, each ending in one `\n`), and `layout`, the parameter-layout
|
|
7
|
+
* fingerprint plugin-hostd's prober took (`omx-layout/1:<sha256>`), or none. Computed here from the raw per-file
|
|
8
|
+
* digests the prober wrote (`IntegrityMeasurement`): nothing here opens a file or hashes bytes plugin-hostd did not
|
|
9
|
+
* already hash.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
import type { IntegrityMeasurement, PinnedFile } from "./measurement/format.js";
|
|
14
|
+
import type { LayerPin } from "./judged-layer.js";
|
|
15
|
+
|
|
16
|
+
/** The scheme name the combined files digest states, plugin-db's `PDB_STORE_FILES_PIN_SCHEME`. */
|
|
17
|
+
export const OMX_FILES_SCHEME = "omx-files/1";
|
|
18
|
+
|
|
19
|
+
/** The combined `omx-files/1` digest of a pin's files, plugin-db's one way to fold several file hashes into one. */
|
|
20
|
+
export function omxFilesDigest(files: readonly PinnedFile[]): string {
|
|
21
|
+
const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
22
|
+
const text = [`${OMX_FILES_SCHEME}\n`, ...sorted.map((f) => `${f.path}\t${f.sha256}\n`)].join("");
|
|
23
|
+
return createHash("sha256").update(text).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The pin plugin-db holds for an integrity measurement, or `undefined` when it took no whole pin (no files). */
|
|
27
|
+
export function layerPinOf(integrity: Pick<IntegrityMeasurement, "files" | "layout">): LayerPin | undefined {
|
|
28
|
+
if (integrity.files === undefined || integrity.files.length === 0) return undefined;
|
|
29
|
+
return {
|
|
30
|
+
files: omxFilesDigest(integrity.files),
|
|
31
|
+
layout: integrity.layout === undefined ? null : `${integrity.layout.scheme}:${integrity.layout.sha256}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/qualify-sync.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { iriPath, lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
|
|
|
35
35
|
import { judgmentPinEntries, pinClearLine, pinLineUri, pinsFile, type PinEntry } from "./pins-file.js";
|
|
36
36
|
import { POOL_RESULT, completePool, judgePoolRun, pinAfterPool, pinFromRecordWithPool, poolEnv, spawnedPoolRunner, type PoolRunner } from "./qualify-pool.js";
|
|
37
37
|
import { hostdPinFiles, type PinFilesRun } from "./hostd-pin-files.js";
|
|
38
|
+
import { localeOf, reasonText } from "./text-catalog.js";
|
|
38
39
|
import type { Judgment } from "./probe-judge.js";
|
|
39
40
|
import { qualifyTargets, type ProbeRun } from "./probe-runner.js";
|
|
40
41
|
import { GRID_FULL, hostCells, parseGridSpec, type GridCell, type GridSpec } from "./measurement-grid.js";
|
|
@@ -670,7 +671,7 @@ function withHostdFiles(ctx: SyncContext, pin: PinEntry | undefined): PinEntry |
|
|
|
670
671
|
if (pin === undefined || pin.integrity.files === undefined) return pin;
|
|
671
672
|
const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
|
|
672
673
|
if (!r.ok) {
|
|
673
|
-
ctx.log(`${pin.uri}: not pinned: ${r.detail}`);
|
|
674
|
+
ctx.log(`${pin.uri}: not pinned: ${reasonText(r.detail, localeOf(ctx.env))}`);
|
|
674
675
|
return undefined;
|
|
675
676
|
}
|
|
676
677
|
return { ...pin, integrity: { ...pin.integrity, files: r.files } };
|
|
@@ -693,7 +694,7 @@ export function refreshPins(ctx: SyncContext, state: SyncState): { state: SyncSt
|
|
|
693
694
|
if (pin === undefined || pin.integrity.files === undefined) return plugin;
|
|
694
695
|
const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
|
|
695
696
|
if (!r.ok) {
|
|
696
|
-
ctx.log(`${pin.uri}: pin files not refreshed: ${r.detail}`);
|
|
697
|
+
ctx.log(`${pin.uri}: pin files not refreshed: ${reasonText(r.detail, localeOf(ctx.env))}`);
|
|
697
698
|
return plugin;
|
|
698
699
|
}
|
|
699
700
|
const old = new Map(pin.integrity.files.map((f) => [f.path, f.sha256]));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The figures a qualification record holds at one rate and quantum: what plugin-qualify's own
|
|
5
|
+
* menus and the zynthian-ui fork both need to show, read from `plugin-qualify.qualification-record/1`
|
|
6
|
+
* (`qualification-record.ts`) rather than re-derived.
|
|
7
|
+
*
|
|
8
|
+
* `tools/record_figures.py` is the same reader for a Python console (zynthian-ui): the two are
|
|
9
|
+
* held equal over the same fixtures (`record-figures-python-conformance.test.ts`), figure for
|
|
10
|
+
* figure, word for word and code for code.
|
|
11
|
+
*
|
|
12
|
+
* This reader takes no side on the plugin: no tier, no state, no verdict. A figure plugin-qualify
|
|
13
|
+
* did not take is absent here, never a zero and never guessed — that is plugin-db's reader
|
|
14
|
+
* (plugin-db#4) and the tiers already in the record (`RecordTiers`).
|
|
15
|
+
*/
|
|
16
|
+
import type { QualificationRecord } from "./qualification-record.js";
|
|
17
|
+
|
|
18
|
+
/** The latency frames this cell holds: what the plugin declared, and what the run measured, when either was taken. */
|
|
19
|
+
export interface RecordLatencyFigures {
|
|
20
|
+
readonly declaredFrames?: number;
|
|
21
|
+
readonly measuredFrames?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The interposer's RT-safety counts for this cell, as the probe wrote them — never re-summed. */
|
|
25
|
+
export interface RecordRtSafetyFigures {
|
|
26
|
+
/** The interposer was loaded; a count below is zero from a real run, not from no interposer. */
|
|
27
|
+
readonly interposer?: boolean;
|
|
28
|
+
readonly swept?: boolean;
|
|
29
|
+
readonly sweepApplicable?: boolean;
|
|
30
|
+
readonly allocations?: number;
|
|
31
|
+
readonly locks?: number;
|
|
32
|
+
readonly syscalls?: number;
|
|
33
|
+
readonly threadViolations?: number;
|
|
34
|
+
readonly processErrors?: number;
|
|
35
|
+
/** How many events the interposer logged (`malloc ? ?`, ...); the words themselves are for a log, not this reader. */
|
|
36
|
+
readonly events?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The figures of one qualification record at one rate and quantum. */
|
|
40
|
+
export interface RecordFigures {
|
|
41
|
+
readonly latency?: RecordLatencyFigures;
|
|
42
|
+
/** The median cost of this cell, in nanoseconds per sample (`ProbeCost.median`'s own unit). */
|
|
43
|
+
readonly costNsPerSampleMedian?: number;
|
|
44
|
+
readonly rtSafety?: RecordRtSafetyFigures;
|
|
45
|
+
/** Whether the continuous soak ran at this rate; `false` when the record never names a soak reading here. */
|
|
46
|
+
readonly soakRan: boolean;
|
|
47
|
+
/** This cell's own outcome code, carried verbatim, exactly when it is a crash. */
|
|
48
|
+
readonly crashCode?: string;
|
|
49
|
+
/** This cell's own outcome code, carried verbatim, exactly when it is a supervisor timeout. */
|
|
50
|
+
readonly timeoutCode?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PROBE_CRASHED = "probe.crashed";
|
|
54
|
+
const PROBE_TIMEOUT = "probe.timeout";
|
|
55
|
+
|
|
56
|
+
function rtSafetyFiguresOf(rtSafety: QualificationRecord["measurements"]["grid"][number]["rtSafety"]): RecordRtSafetyFigures | undefined {
|
|
57
|
+
if (rtSafety === undefined) return undefined;
|
|
58
|
+
return {
|
|
59
|
+
...(rtSafety.interposer === undefined ? {} : { interposer: rtSafety.interposer }),
|
|
60
|
+
...(rtSafety.swept === undefined ? {} : { swept: rtSafety.swept }),
|
|
61
|
+
...(rtSafety.sweepApplicable === undefined ? {} : { sweepApplicable: rtSafety.sweepApplicable }),
|
|
62
|
+
...(rtSafety.allocations === undefined ? {} : { allocations: rtSafety.allocations }),
|
|
63
|
+
...(rtSafety.locks === undefined ? {} : { locks: rtSafety.locks }),
|
|
64
|
+
...(rtSafety.syscalls === undefined ? {} : { syscalls: rtSafety.syscalls }),
|
|
65
|
+
...(rtSafety.threadViolations === undefined ? {} : { threadViolations: rtSafety.threadViolations }),
|
|
66
|
+
...(rtSafety.processErrors === undefined ? {} : { processErrors: rtSafety.processErrors }),
|
|
67
|
+
...(rtSafety.events === undefined ? {} : { events: rtSafety.events.length }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function latencyFiguresOf(latency: QualificationRecord["measurements"]["grid"][number]["latency"]): RecordLatencyFigures | undefined {
|
|
72
|
+
const declaredFrames = (latency as { declaredFrames?: number } | undefined)?.declaredFrames;
|
|
73
|
+
const measuredFrames = (latency as { measuredFrames?: number } | undefined)?.measuredFrames;
|
|
74
|
+
if (declaredFrames === undefined && measuredFrames === undefined) return undefined;
|
|
75
|
+
return {
|
|
76
|
+
...(declaredFrames === undefined ? {} : { declaredFrames }),
|
|
77
|
+
...(measuredFrames === undefined ? {} : { measuredFrames }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether the record names a continuous soak reading at this rate (`measurements.behaviour.soak.perRate`). */
|
|
82
|
+
function soakRanAt(record: QualificationRecord, rate: number): boolean {
|
|
83
|
+
const soak = record.measurements.behaviour?.soak as { readonly perRate?: Readonly<Record<string, unknown>> } | undefined;
|
|
84
|
+
return soak?.perRate?.[String(rate)] !== undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The figures `plugin-qualify.qualification-record/1` holds at one rate and quantum: the cell the
|
|
89
|
+
* record's grid names at that pair, else every per-cell figure is absent and `soakRan` alone still
|
|
90
|
+
* answers for the rate.
|
|
91
|
+
*/
|
|
92
|
+
export function recordFigures(record: QualificationRecord, rate: number, quantum: number): RecordFigures {
|
|
93
|
+
const point = record.measurements.grid.find((p) => p.rate === rate && p.quantum === quantum);
|
|
94
|
+
return {
|
|
95
|
+
...(point === undefined ? {} : { ...wrapLatency(point.latency), ...wrapCost(point.cost), ...wrapRtSafety(point.rtSafety) }),
|
|
96
|
+
soakRan: soakRanAt(record, rate),
|
|
97
|
+
...(point?.outcome === PROBE_CRASHED ? { crashCode: point.outcome } : {}),
|
|
98
|
+
...(point?.outcome === PROBE_TIMEOUT ? { timeoutCode: point.outcome } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function wrapLatency(latency: QualificationRecord["measurements"]["grid"][number]["latency"]): Pick<RecordFigures, "latency"> | Record<string, never> {
|
|
103
|
+
const l = latencyFiguresOf(latency);
|
|
104
|
+
return l === undefined ? {} : { latency: l };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function wrapCost(cost: QualificationRecord["measurements"]["grid"][number]["cost"]): Pick<RecordFigures, "costNsPerSampleMedian"> | Record<string, never> {
|
|
108
|
+
return cost === undefined ? {} : { costNsPerSampleMedian: cost.median };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function wrapRtSafety(rtSafety: QualificationRecord["measurements"]["grid"][number]["rtSafety"]): Pick<RecordFigures, "rtSafety"> | Record<string, never> {
|
|
112
|
+
const rt = rtSafetyFiguresOf(rtSafety);
|
|
113
|
+
return rt === undefined ? {} : { rtSafety: rt };
|
|
114
|
+
}
|