@openmixer/plugin-qualify 0.11.0 → 0.13.0

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 (51) hide show
  1. package/README.md +7 -1
  2. package/dist/clap-qualify.d.ts +60 -4
  3. package/dist/clap-qualify.js +141 -3
  4. package/dist/clap-qualify.js.map +1 -1
  5. package/dist/host-profiles.d.ts +1 -2
  6. package/dist/host-profiles.js +12 -2
  7. package/dist/host-profiles.js.map +1 -1
  8. package/dist/hosting-codes.d.ts +77 -57
  9. package/dist/hosting-codes.js +89 -77
  10. package/dist/hosting-codes.js.map +1 -1
  11. package/dist/hosting-suitability.d.ts +12 -4
  12. package/dist/hosting-suitability.js +18 -16
  13. package/dist/hosting-suitability.js.map +1 -1
  14. package/dist/measurement/cli.d.ts +8 -1
  15. package/dist/measurement/cli.js +15 -1
  16. package/dist/measurement/cli.js.map +1 -1
  17. package/dist/measurement/format.d.ts +45 -5
  18. package/dist/measurement/format.js +46 -3
  19. package/dist/measurement/format.js.map +1 -1
  20. package/dist/measurement/probe-json.js +3 -0
  21. package/dist/measurement/probe-json.js.map +1 -1
  22. package/dist/measurement/sweep-jsonl.js +2 -1
  23. package/dist/measurement/sweep-jsonl.js.map +1 -1
  24. package/dist/measurement/vocabulary.d.ts +39 -22
  25. package/dist/measurement/vocabulary.js +93 -145
  26. package/dist/measurement/vocabulary.js.map +1 -1
  27. package/dist/probe-judge.d.ts +6 -1
  28. package/dist/probe-judge.js +7 -3
  29. package/dist/probe-judge.js.map +1 -1
  30. package/dist/probe-output.d.ts +3 -0
  31. package/dist/probe-output.js +5 -0
  32. package/dist/probe-output.js.map +1 -1
  33. package/dist/qualification-record.d.ts +3 -1
  34. package/dist/qualification-record.js.map +1 -1
  35. package/fixtures/clap-scan/fake.json +109 -7
  36. package/fixtures/probe-output/success-msi.json +11 -0
  37. package/package.json +2 -2
  38. package/src/clap-qualify.ts +177 -5
  39. package/src/host-profiles.ts +12 -2
  40. package/src/hosting-codes.ts +182 -77
  41. package/src/hosting-suitability.ts +25 -18
  42. package/src/measurement/cli.ts +21 -1
  43. package/src/measurement/format.ts +81 -3
  44. package/src/measurement/probe-json.ts +3 -0
  45. package/src/measurement/sweep-jsonl.ts +2 -1
  46. package/src/measurement/vocabulary.ts +148 -149
  47. package/src/probe-judge.ts +13 -5
  48. package/src/probe-output.ts +6 -0
  49. package/src/qualification-record.ts +9 -2
  50. package/tools/benchmark.py +104 -13
  51. package/tools/lv2-measure.mjs +2 -0
@@ -148,12 +148,11 @@ export type HostingCode = (typeof HOSTING_CODE)[keyof typeof HOSTING_CODE];
148
148
  */
149
149
  export const CLAP_CORE_REFUSALS = {
150
150
  headlessFailed: "clapHeadlessFailed",
151
- notAudioEffect: "clapNotAudioEffect",
152
151
  noteInput: "clapNoteInput",
153
152
  noAudioInput: "topologyNoAudioInput",
154
153
  noAudioOutput: "topologyNoAudioOutput",
155
154
  widerThanStrip: "topologyWiderThanStrip",
156
- extraInputs: "topologyExtraInputs",
155
+ tooManyPorts: "topologyTooManyPorts",
157
156
  crashedLive: "stabilityCrashedLive",
158
157
  outputNonFinite: "outputNonFinite",
159
158
  } as const satisfies Record<string, keyof typeof HOSTING_CODE>;
@@ -164,12 +163,11 @@ export const CLAP_CORE_REFUSALS = {
164
163
  * refusal so the renderer holds no prose. A refusal without a line renders bare.
165
164
  */
166
165
  export const CLAP_CORE_REFUSAL_DESCRIPTIONS: { readonly [K in keyof typeof CLAP_CORE_REFUSALS]?: string } = {
167
- notAudioEffect: "the plugin is not an audio effect",
168
166
  noteInput: "the plugin declares a note input the configuration refuses",
169
167
  noAudioInput: "the plugin has no audio input",
170
168
  noAudioOutput: "the plugin has no audio output",
171
169
  widerThanStrip: "the plugin is wider than the strip",
172
- extraInputs: "the plugin has extra inputs, fed silence",
170
+ tooManyPorts: "the plugin has more ports than the auxiliary limit allows",
173
171
  crashedLive: "the plugin crashed live on this rig",
174
172
  outputNonFinite: "the plugin produced non-finite output",
175
173
  };
@@ -320,8 +318,14 @@ import { costFieldsMissing, costPointKey } from "./cost-fields.js";
320
318
  export interface ClapLatencyAtRate {
321
319
  /** `latency.get()` after `activate` at this rate; a plugin without the ext declares 0. */
322
320
  readonly declaredFrames: number;
323
- /** Where the qualifier's impulse landed through `process()`, in frames. */
321
+ /** Where the qualifier's impulse landed through `process()`, in frames; for an instrument, where a note's
322
+ * sound began. */
324
323
  readonly measuredFrames: number;
324
+ /** `note`: a plugin with no main audio input, measured from a note struck at frame 0 (the onset of its
325
+ * sound, which an attack envelope may put later than its latency). Absent: from an impulse. */
326
+ readonly from?: "note";
327
+ /** `false`: the note was struck and nothing was heard, so there is no onset to read. */
328
+ readonly heard?: boolean;
325
329
  }
326
330
 
327
331
  /**
@@ -690,9 +694,6 @@ export function featureReasons(
690
694
  }),
691
695
  );
692
696
  }
693
- if ((features.cvPorts ?? 0) > 0) {
694
- out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.featuresCv));
695
- }
696
697
  }
697
698
  if (descriptor.hasMidiIn && !instruments) {
698
699
  out.push(reason("features", "objective", "conditional", HOSTING_CODE.featuresMidiIn));
@@ -815,7 +816,7 @@ export function hostingTopologyReasons(
815
816
  ];
816
817
  }
817
818
  return [
818
- reason("topology", "objective", "conditional", HOSTING_CODE.topologyExtraInputs, { ins, outs }),
819
+ reason("topology", "objective", "conditional", HOSTING_CODE.topologyTooManyPorts, { ins, outs }),
819
820
  ];
820
821
  }
821
822
 
@@ -839,7 +840,7 @@ export function clapTopologyReasons(clap: PluginClapMeasurement | undefined, ins
839
840
  return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyWiderThanStrip, { ins, outs })];
840
841
  }
841
842
  if (clap.sidechainInputs > 0) {
842
- return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyExtraInputs, { ins, outs, sidechains: clap.sidechainInputs })];
843
+ return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyTooManyPorts, { ins, outs, sidechains: clap.sidechainInputs })];
843
844
  }
844
845
  return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyMatched, { ins, outs })];
845
846
  }
@@ -847,7 +848,10 @@ export function clapTopologyReasons(clap: PluginClapMeasurement | undefined, ins
847
848
  /**
848
849
  * The CLAP arm of `latency` (CLAP spec §3): declared = measured at EVERY declared rate earns;
849
850
  * one rate that lies refuses (`unsuitable` — compensation reads the figure as truth); no rate
850
- * measured is `unknown`.
851
+ * measured is `unknown`. An instrument has no input to put an impulse in, so its figure is the onset
852
+ * of a note struck at frame 0: an onset before the declared latency is the same lie, one at or after
853
+ * it is resolved (an attack envelope sounds later than the plugin's delay, and that is no latency),
854
+ * and a note that was never heard is unmeasured.
851
855
  */
852
856
  export function clapLatencyReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
853
857
  const rates = clap ? Object.keys(clap.latency) : [];
@@ -856,7 +860,11 @@ export function clapLatencyReasons(clap: PluginClapMeasurement | undefined): rea
856
860
  }
857
861
  for (const rate of rates) {
858
862
  const at = clap.latency[rate]!;
859
- if (at.declaredFrames !== at.measuredFrames) {
863
+ if (at.from === "note" && at.heard === false) {
864
+ return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured, { rate: Number(rate) })];
865
+ }
866
+ const lies = at.from === "note" ? at.measuredFrames < at.declaredFrames : at.declaredFrames !== at.measuredFrames;
867
+ if (lies) {
860
868
  return [
861
869
  reason("latency", "objective", "unsuitable", HOSTING_CODE.clapLatencyMismatch, {
862
870
  rate: Number(rate), declared: at.declaredFrames, measured: at.measuredFrames,
@@ -886,11 +894,6 @@ export function clapFeatureReasons(
886
894
  }
887
895
  const out: HostingReason[] = [];
888
896
  const offered = profile.clapExtensions;
889
- const kindHosted =
890
- clap.features.includes(CLAP_FEATURE_AUDIO_EFFECT) || (profile.instruments && clap.features.includes(CLAP_FEATURE_INSTRUMENT));
891
- if (!kindHosted) {
892
- out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapNotAudioEffect, { features: clap.features.join(" ") }));
893
- }
894
897
  if (!clap.headlessOk) {
895
898
  out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapHeadlessFailed));
896
899
  } else {
@@ -978,12 +981,15 @@ export function classifyPluginHosting(
978
981
  // report could agree with itself while every served descriptor read unknown.
979
982
  // The CLAP arm: the SAME six dimensions, fed from the
980
983
  // qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
981
- // the interposer's counts and the cost are format-blind and read unchanged.
984
+ // the interposer's counts and the cost are format-blind and read unchanged. A host that plays
985
+ // notes (an instrument host) also reads the note sweep: an instrument is clean at rest, so the
986
+ // parameter sweep alone says nothing about what a voice does.
982
987
  const reasons: HostingReason[] = descriptor.format === "clap"
983
988
  ? [
984
989
  ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
985
990
  ...rtSafetyReasons(descriptor.rtSafety),
986
991
  ...clapRtReasons(descriptor.clap),
992
+ ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.clap?.noteInputs ?? (descriptor.hasMidiIn ? 1 : 0)) : []),
987
993
  ...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
988
994
  ...hostingCostReasons(descriptor, options, policy),
989
995
  ...clapLatencyReasons(descriptor.clap),
@@ -992,6 +998,7 @@ export function classifyPluginHosting(
992
998
  : [
993
999
  ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
994
1000
  ...rtSafetyReasons(descriptor.rtSafety),
1001
+ ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.hasMidiIn ? 1 : 0) : []),
995
1002
  ...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
996
1003
  ...hostingCostReasons(descriptor, options, policy),
997
1004
  ...hostingLatencyReasons(descriptor, profile.latency),
@@ -54,6 +54,9 @@ export const TOOL_NAME = "lv2-measure";
54
54
  */
55
55
  export const TOOL_VERSION = "0.1.0";
56
56
 
57
+ /** The most notes a cost run holds: one per MIDI key, as the probers cap it. */
58
+ export const PROBE_NOTES_MAX = 128;
59
+
57
60
  /** A fully resolved invocation. */
58
61
  export interface MeasureOptions {
59
62
  /** Where the document goes. Absent = stdout. */
@@ -70,6 +73,11 @@ export interface MeasureOptions {
70
73
  readonly sweep: boolean;
71
74
  /** Core to pin the cost pass to. Absent = let the prober choose. */
72
75
  readonly costCpu?: number;
76
+ /**
77
+ * The notes the cost pass holds down while it times an instrument (`--probe-notes`, the console's
78
+ * `SYNTH_VOICE_PROBE_NOTES`), basis `probe`. Absent = an instrument is unmeasured `voices-unknown`.
79
+ */
80
+ readonly probeNotes?: number;
73
81
  readonly label?: string;
74
82
  /** Directory holding `scan.py` and `benchmark.py`. */
75
83
  readonly toolDir: string;
@@ -91,7 +99,7 @@ export interface MeasureOptions {
91
99
  * them: the provider's kinds (`--features`), its buf-size declarations (`--block-declares`) and its log
92
100
  * modes (`--log`). A conformance test reads both.
93
101
  */
94
- export const LV2_FEATURE_KINDS = ["map", "unmap", "options", "bounded", "worker", "default-state", "log"] as const;
102
+ export const LV2_FEATURE_KINDS = ["map", "unmap", "options", "bounded", "worker", "default-state", "log", "rtmempool"] as const;
95
103
  export const LV2_BLOCK_DECLARATIONS = ["bounded", "fixed", "pow2"] as const;
96
104
  export const LV2_LOG_MODES = ["silent", "route", "capture"] as const;
97
105
 
@@ -203,6 +211,7 @@ export function buildPipeline(options: MeasureOptions, native?: HostingTools): P
203
211
  "--cost-provenance",
204
212
  costProvenance,
205
213
  ...(options.costCpu === undefined ? [] : ["--cost-cpu", String(options.costCpu)]),
214
+ ...(options.probeNotes === undefined ? [] : ["--probe-notes", String(options.probeNotes)]),
206
215
  ],
207
216
  produces: cost,
208
217
  dimension: "cost",
@@ -356,6 +365,7 @@ export function parseMeasureArgs(
356
365
  let workDir = defaults.workDir;
357
366
  let sweep = false;
358
367
  let costCpu: number | undefined;
368
+ let probeNotes: number | undefined;
359
369
  let label: string | undefined;
360
370
  let toolDir = defaults.toolDir;
361
371
  let python = env.PYTHON ?? "python3";
@@ -430,6 +440,15 @@ export function parseMeasureArgs(
430
440
  costCpu = cpu;
431
441
  break;
432
442
  }
443
+ case "--probe-notes": {
444
+ const value = next();
445
+ const notes = value === undefined ? Number.NaN : Number(value);
446
+ if (!Number.isInteger(notes) || notes < 1 || notes > PROBE_NOTES_MAX) {
447
+ return { ok: false, error: `--probe-notes needs a count of notes, 1 to ${PROBE_NOTES_MAX}` };
448
+ }
449
+ probeNotes = notes;
450
+ break;
451
+ }
433
452
  case "--label": {
434
453
  const value = next();
435
454
  if (value === undefined) return { ok: false, error: "--label needs text" };
@@ -494,6 +513,7 @@ export function parseMeasureArgs(
494
513
  ...(scanPath === undefined ? {} : { scanPath }),
495
514
  ...(plugins === undefined ? {} : { plugins }),
496
515
  ...(costCpu === undefined ? {} : { costCpu }),
516
+ ...(probeNotes === undefined ? {} : { probeNotes }),
497
517
  ...(label === undefined ? {} : { label }),
498
518
  ...(featureFlags.length === 0 ? {} : { featureFlags }),
499
519
  },
@@ -58,10 +58,12 @@ import {
58
58
  type HostLatencyHandling,
59
59
  type MeasurementDimension,
60
60
  type VerdictRating,
61
+ type VoiceBasis,
61
62
  HOST_ISOLATIONS,
62
63
  HOST_LATENCY_HANDLING,
63
64
  MEASUREMENT_DIMENSIONS,
64
65
  VERDICT_RATINGS,
66
+ isVoiceBasis,
65
67
  readDimension,
66
68
  } from "./vocabulary.js";
67
69
 
@@ -74,8 +76,10 @@ export const MEASUREMENT_FORMAT_MAJOR = 1;
74
76
  /** The minor this build writes. Additive-only within {@link MEASUREMENT_FORMAT_MAJOR}.
75
77
  * `1.1` added the three hosting dimensions — {@link StabilityMeasurement},
76
78
  * {@link RtSafetyMeasurement}, {@link FeaturesMeasurement} — which a 1.0 reader skips.
77
- * `1.2` added the verdicts per host profile; `1.3` the {@link IntegrityMeasurement} block. */
78
- export const MEASUREMENT_FORMAT_MINOR = 3;
79
+ * `1.2` added the verdicts per host profile; `1.3` the {@link IntegrityMeasurement} block; `1.4` the notes
80
+ * an instrument's cost was measured with ({@link CostAtRate.voices}); `1.5` the note sweep an instrument's
81
+ * `run()` was counted under ({@link RtSafetyMeasurement.noteSweep}). */
82
+ export const MEASUREMENT_FORMAT_MINOR = 5;
79
83
 
80
84
  /** The version string this build writes, e.g. `"1.0"`. */
81
85
  export const MEASUREMENT_FORMAT_VERSION = `${MEASUREMENT_FORMAT_MAJOR}.${MEASUREMENT_FORMAT_MINOR}`;
@@ -143,7 +147,7 @@ export interface MeasurementHost {
143
147
 
144
148
  /** How the latency figures in this document were taken. */
145
149
  export interface LatencyMethod {
146
- /** A {@link ../vocabulary.METHOD_TERMS} code — `impulse-onset` or `declared-port`. */
150
+ /** A {@link ../vocabulary.METHOD_TERMS} code — `impulse-onset`, `declared-port` or `note-onset`. */
147
151
  readonly method: string;
148
152
  readonly blockFrames: number;
149
153
  /** What was fed in, in words. An onset measured through an opened gate is not the same
@@ -270,6 +274,17 @@ export interface LatencyMeasurement {
270
274
  readonly unmeasuredDetail?: string;
271
275
  }
272
276
 
277
+ /**
278
+ * The notes an instrument held down while its cost was timed (1.4): how many, where that count came
279
+ * from ({@link VoiceBasis}), and the voice capacity the plugin declared at that rate when it declares one.
280
+ * An instrument costs nothing until a voice plays, so its figure means nothing without this.
281
+ */
282
+ export interface VoicesHeld {
283
+ readonly held: number;
284
+ readonly basis: VoiceBasis;
285
+ readonly capacity?: number;
286
+ }
287
+
273
288
  /** One rate's cost reading. Per SAMPLE, so it is independent of the block size. */
274
289
  export interface CostAtRate {
275
290
  readonly nsPerSampleMedian: number;
@@ -286,6 +301,8 @@ export interface CostAtRate {
286
301
  /** Timed, but emitted silence under a stimulus that should have passed — the figure may
287
302
  * describe a short-circuit path rather than the DSP. */
288
303
  readonly silentOutput?: boolean;
304
+ /** 1.4: for an instrument, the notes held while it was timed. Absent for an effect. */
305
+ readonly voices?: VoicesHeld;
289
306
  }
290
307
 
291
308
  /** Everything one run learned about one plugin's CPU cost. */
@@ -369,6 +386,27 @@ export interface StabilityMeasurement {
369
386
  readonly unmeasuredDetail?: string;
370
387
  }
371
388
 
389
+ /**
390
+ * 1.5: an instrument played through its MIDI input while the interposer counted, apart from the control
391
+ * sweep — a chord, bend and a controller, pressure, the chord let go, a burst of note ons and their offs,
392
+ * all notes off. `applicable: false` is a plugin with no MIDI input; the counts are present only when
393
+ * the interposer was loaded.
394
+ */
395
+ export interface NoteSweepMeasurement {
396
+ readonly applicable: boolean;
397
+ readonly dialect?: "clap" | "midi";
398
+ readonly blocks?: number;
399
+ readonly blockFrames?: number;
400
+ /** The note, expression and MIDI events the sweep sent. */
401
+ readonly noteEvents?: number;
402
+ readonly processErrors?: number;
403
+ readonly nonFinite?: boolean;
404
+ readonly interposer?: boolean;
405
+ readonly allocationsInRun?: number;
406
+ readonly locksInRun?: number;
407
+ readonly syscallsInRun?: number;
408
+ }
409
+
372
410
  /**
373
411
  * What an interposer counted INSIDE `run()`. `rate` and `blockFrames` are the pair it ran at —
374
412
  * a count is a property of the code paths the run took there. Readable when all three counts
@@ -391,6 +429,8 @@ export interface RtSafetyMeasurement {
391
429
  readonly syscallsInRun?: number;
392
430
  /** Repeats disagreed. */
393
431
  readonly variable?: boolean;
432
+ /** 1.5: the same counts taken while the plugin was played through its MIDI input. */
433
+ readonly noteSweep?: NoteSweepMeasurement;
394
434
  readonly unmeasuredReason?: string;
395
435
  readonly unmeasuredDetail?: string;
396
436
  }
@@ -834,9 +874,20 @@ function readCostAtRate(value: unknown): CostAtRate | undefined {
834
874
  ...optional("nsPerSampleMax", finiteNumber(value.nsPerSampleMax)),
835
875
  ...optional("warmupNsPerSampleMedian", finiteNumber(value.warmupNsPerSampleMedian)),
836
876
  ...optional("silentOutput", booleanValue(value.silentOutput)),
877
+ ...optional("voices", readVoicesHeld(value.voices)),
837
878
  };
838
879
  }
839
880
 
881
+ /** The notes held, or `undefined` unless the count and a known basis are there. */
882
+ export function readVoicesHeld(value: unknown): VoicesHeld | undefined {
883
+ if (!isRecord(value)) return undefined;
884
+ const held = positiveNumber(value.held);
885
+ const basis = isVoiceBasis(value.basis) ? value.basis : undefined;
886
+ const capacity = finiteNumber(value.capacity);
887
+ if (held === undefined || !Number.isInteger(held) || basis === undefined) return undefined;
888
+ return { held, basis, ...(capacity !== undefined && Number.isInteger(capacity) && capacity >= 0 ? { capacity } : {}) };
889
+ }
890
+
840
891
  function readCost(value: unknown): CostMeasurement | undefined {
841
892
  if (!isRecord(value)) return undefined;
842
893
  return {
@@ -942,10 +993,37 @@ function readRtSafety(value: unknown): RtSafetyMeasurement | undefined {
942
993
  ...optional("locksInRun", finiteNumber(value.locksInRun)),
943
994
  ...optional("syscallsInRun", finiteNumber(value.syscallsInRun)),
944
995
  ...optional("variable", booleanValue(value.variable)),
996
+ ...optional("noteSweep", readNoteSweep(value.noteSweep)),
945
997
  ...reasonOf(value),
946
998
  };
947
999
  }
948
1000
 
1001
+ /** A note sweep, or `undefined` unless it says whether it was applicable; a count it cannot vouch for is left out. */
1002
+ export function readNoteSweep(value: unknown): NoteSweepMeasurement | undefined {
1003
+ if (!isRecord(value)) return undefined;
1004
+ const applicable = booleanValue(value.applicable);
1005
+ if (applicable === undefined) return undefined;
1006
+ if (!applicable) return { applicable };
1007
+ const dialect = value.dialect === "clap" || value.dialect === "midi" ? value.dialect : undefined;
1008
+ const counted = (v: unknown) => {
1009
+ const n = finiteNumber(v);
1010
+ return n !== undefined && Number.isInteger(n) && n >= 0 ? n : undefined;
1011
+ };
1012
+ return {
1013
+ applicable,
1014
+ ...optional("dialect", dialect),
1015
+ ...optional("blocks", counted(value.blocks)),
1016
+ ...optional("blockFrames", counted(value.blockFrames)),
1017
+ ...optional("noteEvents", counted(value.noteEvents)),
1018
+ ...optional("processErrors", counted(value.processErrors)),
1019
+ ...optional("nonFinite", booleanValue(value.nonFinite)),
1020
+ ...optional("interposer", booleanValue(value.interposer)),
1021
+ ...optional("allocationsInRun", counted(value.allocationsInRun)),
1022
+ ...optional("locksInRun", counted(value.locksInRun)),
1023
+ ...optional("syscallsInRun", counted(value.syscallsInRun)),
1024
+ };
1025
+ }
1026
+
949
1027
  function readFeatures(value: unknown): FeaturesMeasurement | undefined {
950
1028
  if (!isRecord(value)) return undefined;
951
1029
  return {
@@ -35,6 +35,7 @@ import type {
35
35
  PluginTopology,
36
36
  } from "./format.js";
37
37
  import { booleanValue, finiteNumber, isRecord, nonEmptyString, optional, positiveNumber } from "./guards.js";
38
+ import { readVoicesHeld } from "./format.js";
38
39
  import type { MeasurementDimension } from "./vocabulary.js";
39
40
 
40
41
  /** One free-text prober failure, and the vocabulary term it means. */
@@ -55,6 +56,7 @@ export const PROBE_REASON_RULES: readonly ReasonRule[] = [
55
56
  { match: /^no-audio-in$/, code: "no-audio-in" },
56
57
  { match: /^no-audio-out$/, code: "no-audio-out" },
57
58
  { match: /^silent-output$/, code: "silent-output" },
59
+ { match: /^voices-unknown$/, code: "voices-unknown" },
58
60
  { match: /hosting\.worker\.nonconforming/, code: "worker-nonconforming" },
59
61
  { match: /worker crashed|worker desync|worker unavailable/, code: "probe-crashed" },
60
62
  { match: /timed out|timeout/, code: "probe-timeout" },
@@ -198,6 +200,7 @@ function readCost(value: unknown): CostMeasurement | undefined {
198
200
  ...optional("nsPerSampleMax", finiteNumber(reading.nsPerSampleMax)),
199
201
  ...optional("warmupNsPerSampleMedian", finiteNumber(reading.warmupNsPerSampleMedian)),
200
202
  ...optional("silentOutput", booleanValue(reading.silentOutput)),
203
+ ...optional("voices", readVoicesHeld(reading.voices)),
201
204
  };
202
205
  }
203
206
  }
@@ -29,7 +29,7 @@ import type {
29
29
  SoakReading,
30
30
  StabilityMeasurement,
31
31
  } from "./format.js";
32
- import { readIntegrity, readLifecycle, readSoakAtRate, readThreads } from "./format.js";
32
+ import { readIntegrity, readLifecycle, readNoteSweep, readSoakAtRate, readThreads } from "./format.js";
33
33
  import { booleanValue, finiteNumber, isRecord, mapArray, nonEmptyString, optional, positiveNumber } from "./guards.js";
34
34
 
35
35
  /** The sweep's three passes, each a list of parsed JSONL lines. Only `measurements` is required. */
@@ -106,6 +106,7 @@ function rtSafetyOf(row: Record<string, unknown>): RtSafetyMeasurement {
106
106
  ...optional("locksInRun", finiteNumber(rt.locksInRun)),
107
107
  ...optional("syscallsInRun", finiteNumber(rt.syscallsInRun)),
108
108
  ...optional("variable", booleanValue(rt.variable)),
109
+ ...optional("noteSweep", readNoteSweep(rt.noteSweep)),
109
110
  };
110
111
  }
111
112