@openmixer/plugin-qualify 0.17.5 → 0.18.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 (109) hide show
  1. package/README.md +127 -13
  2. package/bin/plugin-qualify-sync.mjs +3 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +7 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/direct-limit.d.ts +27 -0
  7. package/dist/direct-limit.js +52 -0
  8. package/dist/direct-limit.js.map +1 -0
  9. package/dist/distributed-records.d.ts +4 -2
  10. package/dist/distributed-records.js +1 -1
  11. package/dist/distributed-records.js.map +1 -1
  12. package/dist/host-admission.d.ts +12 -2
  13. package/dist/host-admission.js +24 -4
  14. package/dist/host-admission.js.map +1 -1
  15. package/dist/host-profiles.d.ts +8 -0
  16. package/dist/host-profiles.js +16 -1
  17. package/dist/host-profiles.js.map +1 -1
  18. package/dist/hosting-codes.generated.d.ts +10 -0
  19. package/dist/hosting-codes.generated.js +10 -0
  20. package/dist/hosting-codes.generated.js.map +1 -1
  21. package/dist/hosting-suitability.d.ts +34 -8
  22. package/dist/hosting-suitability.js +92 -7
  23. package/dist/hosting-suitability.js.map +1 -1
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/measurement/format.d.ts +54 -8
  28. package/dist/measurement/format.js +54 -3
  29. package/dist/measurement/format.js.map +1 -1
  30. package/dist/measurement/plan.d.ts +3 -3
  31. package/dist/measurement/plan.js +1 -1
  32. package/dist/measurement-grid.d.ts +8 -0
  33. package/dist/measurement-grid.js +4 -0
  34. package/dist/measurement-grid.js.map +1 -1
  35. package/dist/memory-bound.d.ts +6 -0
  36. package/dist/memory-bound.js +8 -0
  37. package/dist/memory-bound.js.map +1 -1
  38. package/dist/memory-budget.d.ts +39 -0
  39. package/dist/memory-budget.js +32 -0
  40. package/dist/memory-budget.js.map +1 -0
  41. package/dist/probe-judge.d.ts +11 -0
  42. package/dist/probe-judge.js +26 -10
  43. package/dist/probe-judge.js.map +1 -1
  44. package/dist/probe-merge.js +6 -5
  45. package/dist/probe-merge.js.map +1 -1
  46. package/dist/probe-output-fixtures.d.ts +6 -0
  47. package/dist/probe-output-fixtures.js +8 -0
  48. package/dist/probe-output-fixtures.js.map +1 -1
  49. package/dist/probe-output.d.ts +4 -2
  50. package/dist/probe-output.js +2 -1
  51. package/dist/probe-output.js.map +1 -1
  52. package/dist/probe-runner.js +7 -3
  53. package/dist/probe-runner.js.map +1 -1
  54. package/dist/qualification-record.d.ts +116 -10
  55. package/dist/qualification-record.js +34 -30
  56. package/dist/qualification-record.js.map +1 -1
  57. package/dist/qualify-adapter-pass.d.ts +67 -0
  58. package/dist/qualify-adapter-pass.js +255 -0
  59. package/dist/qualify-adapter-pass.js.map +1 -0
  60. package/dist/qualify-declarations.d.ts +53 -9
  61. package/dist/qualify-declarations.js +54 -2
  62. package/dist/qualify-declarations.js.map +1 -1
  63. package/dist/qualify-rejudge.d.ts +81 -0
  64. package/dist/qualify-rejudge.js +421 -0
  65. package/dist/qualify-rejudge.js.map +1 -0
  66. package/dist/qualify-sync.d.ts +72 -1
  67. package/dist/qualify-sync.js +194 -21
  68. package/dist/qualify-sync.js.map +1 -1
  69. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.probe.json +46 -0
  70. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.qualification.json +185 -0
  71. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.probe.json +51 -0
  72. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.qualification.json +232 -0
  73. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.probe.json +51 -0
  74. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.qualification.json +245 -0
  75. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  76. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.qualification.json +145 -0
  77. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  78. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.qualification.json +155 -0
  79. package/package.json +1 -1
  80. package/src/clap-qualify.ts +8 -1
  81. package/src/direct-limit.ts +68 -0
  82. package/src/distributed-records.ts +1 -1
  83. package/src/host-admission.ts +27 -4
  84. package/src/host-profiles.ts +17 -1
  85. package/src/hosting-codes.generated.ts +10 -0
  86. package/src/hosting-suitability.ts +105 -11
  87. package/src/index.ts +3 -0
  88. package/src/measurement/format.ts +97 -3
  89. package/src/measurement/plan.ts +3 -3
  90. package/src/measurement-grid.ts +5 -0
  91. package/src/memory-bound.ts +10 -0
  92. package/src/memory-budget.ts +55 -0
  93. package/src/probe-judge.ts +29 -9
  94. package/src/probe-merge.ts +6 -5
  95. package/src/probe-output-fixtures.ts +10 -1
  96. package/src/probe-output.ts +5 -2
  97. package/src/probe-runner.ts +5 -3
  98. package/src/qualification-record.ts +121 -18
  99. package/src/qualify-adapter-pass.ts +278 -0
  100. package/src/qualify-declarations.ts +67 -3
  101. package/src/qualify-rejudge.ts +452 -0
  102. package/src/qualify-sync.ts +210 -20
  103. package/tools/_declarations.py +1 -0
  104. package/tools/benchmark.py +25 -2
  105. package/tools/gen-python-declarations.mjs +1 -0
  106. package/tools/lv2-measure.mjs +7 -4
  107. package/tools/lv2_presets.py +1 -1
  108. package/tools/lv2_resolve.py +49 -0
  109. package/tools/mod-host-admit.mjs +23 -6
@@ -30,6 +30,7 @@ import type {
30
30
  SoakReading,
31
31
  ThreadReading,
32
32
  } from "./measurement/format.js";
33
+ import { memoryWithinBudget, type MemoryReadings } from "./memory-budget.js";
33
34
 
34
35
  /**
35
36
  * What a reason may interpolate: wire-serialisable scalars only. Structurally the same record as
@@ -71,6 +72,8 @@ export interface QualifiedPlugin {
71
72
  readonly lv2Features?: PluginLv2Features;
72
73
  /** The plugin's binary format, the scan's declared fact (CLAP spec §6); absent reads `lv2`. */
73
74
  readonly format?: "lv2" | "clap";
75
+ /** What one instance holds: per rate, or per rate AND quantum as {@link cpuCost} has it. A missing cell is unmeasured. */
76
+ readonly memory?: MemoryReadings;
74
77
  /** What the CLAP prober (`native/clap-qualify`) measured, for a `clap` plugin (CLAP spec §3). */
75
78
  readonly clap?: PluginClapMeasurement;
76
79
  }
@@ -78,14 +81,15 @@ export interface QualifiedPlugin {
78
81
  /** Where a plugin's audio is actually processed. */
79
82
  export type HostingPath = "in-process" | "isolated";
80
83
 
81
- /** The dimensions that decide a host. Six, folded worst-wins. */
84
+ /** The dimensions that decide a host, folded worst-wins. */
82
85
  export type HostingDimension =
83
86
  | "stability"
84
87
  | "rtSafety"
85
88
  | "features"
86
89
  | "cost"
87
90
  | "latency"
88
- | "topology";
91
+ | "topology"
92
+ | "memory";
89
93
 
90
94
  // The in-process feature list and the CLAP host extensions are the `openmixer-console` host
91
95
  // profile's; they live with the profiles (`host-profiles.ts`) and are re-exported here, where the
@@ -446,13 +450,15 @@ export function deadAgainstBest(levelDbfs: number, bestDbfs: number, policy: Hos
446
450
  }
447
451
 
448
452
  /** The classes of soak failure, as the strings `failures` is built from. */
449
- export type SoakFailureKind = "died" | "non-finite" | "dead" | "mostly-silent" | "startup-blast" | "timed-out";
453
+ export type SoakFailureKind = "died" | "non-finite" | "dead" | "mostly-silent" | "startup-blast" | "timed-out" | "leaks";
450
454
 
451
455
  export interface SoakFailure {
452
456
  readonly rate: number;
453
457
  readonly kind: SoakFailureKind;
454
458
  /** Extra words for the string form, e.g. `20dBFS`. */
455
459
  readonly detail?: string;
460
+ /** For `leaks`: what the growth was and the ceiling it passed, which the reason carries. */
461
+ readonly params?: CodedRefusalParams;
456
462
  }
457
463
 
458
464
  /** A soak's reading: what voided it, if anything, and what it proved when nothing did. */
@@ -465,7 +471,7 @@ export interface SoakFindings {
465
471
  * cut, a fact about the harness: it voids the soak but is never a failure of the plugin. */
466
472
  readonly failed: readonly SoakFailure[];
467
473
  readonly ratesTested: number;
468
- /** The largest RSS growth across rates, for the record; not judged. */
474
+ /** The largest RSS growth across rates, warm-up included, for the record; `leaks` is what is judged. */
469
475
  readonly rssGrowthKb: number;
470
476
  }
471
477
 
@@ -473,6 +479,17 @@ export interface SoakFindings {
473
479
  * settled above it cannot be the reference, or every sane rate reads dead against it. */
474
480
  const BLAST_DBFS = 6;
475
481
 
482
+ /**
483
+ * The growth a rate's soak is still showing, in KB per minute of audio: from halfway past warm-up to its highest reading,
484
+ * so that a library that finishes loading a little late is not a leak and a leak of any size is. `undefined` when the soak
485
+ * did not reach those readings: not read as flat.
486
+ */
487
+ export function soakGrowthKbPerMin(v: SoakAtRate): number | undefined {
488
+ const { rssMidKb, midSeconds, rssMaxKb, seconds } = v;
489
+ if (rssMidKb === undefined || midSeconds === undefined || rssMaxKb === undefined || seconds === undefined || seconds <= midSeconds) return undefined;
490
+ return (Math.max(rssMaxKb - rssMidKb, 0) * 60) / (seconds - midSeconds);
491
+ }
492
+
476
493
  /**
477
494
  * Read a soak, rate by rate. A rate voids it if the plugin did not instantiate or died there,
478
495
  * emitted a non-finite window, settled dead against its own best rate ({@link deadAgainstBest};
@@ -516,7 +533,23 @@ export function soakFindings(soak: SoakReading | undefined, policy: HostingPolic
516
533
  continue;
517
534
  }
518
535
  const first = v.firstRmsDbfs ?? -99;
519
- if (first > BLAST_DBFS) failed.push({ rate, kind: "startup-blast", detail: `${first.toFixed(0)}dBFS` });
536
+ if (first > BLAST_DBFS) {
537
+ failed.push({ rate, kind: "startup-blast", detail: `${first.toFixed(0)}dBFS` });
538
+ continue;
539
+ }
540
+ const growth = soakGrowthKbPerMin(v);
541
+ if (growth !== undefined && growth > policy.memoryGrowthKbPerMinCeiling) {
542
+ failed.push({
543
+ rate,
544
+ kind: "leaks",
545
+ detail: `${growth.toFixed(0)}KB/min`,
546
+ params: {
547
+ growthKbPerMin: Math.round(growth),
548
+ ceilingKbPerMin: policy.memoryGrowthKbPerMinCeiling,
549
+ ...(policy.memoryGrowthCeilingProvisional ? { provisional: "yes" as const } : {}),
550
+ },
551
+ });
552
+ }
520
553
  }
521
554
  const failures = failed.map((f) => `${f.kind}@${f.rate}${f.detail === undefined ? "" : `(${f.detail})`}`);
522
555
  return {
@@ -592,6 +625,7 @@ export function stabilityReasons(
592
625
  reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilitySoakFailedAtRate, {
593
626
  failure: f.kind,
594
627
  rate: f.rate,
628
+ ...f.params,
595
629
  }),
596
630
  );
597
631
  }
@@ -650,9 +684,11 @@ export function rtSafetyReasons(
650
684
  if (m.variable === true) {
651
685
  return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotReproducible)];
652
686
  }
687
+ // RT-unsafe is a hosting mode, never a refusal (#196): the plugin loads and runs in a worker of its own, never
688
+ // grouped with RT-safe plugins; conditional is isolated in the pin. Crashes, hangs and load failures stay unsuitable.
653
689
  if (rtSafetyViolating(m)) {
654
690
  return [
655
- reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyViolations, {
691
+ reason("rtSafety", "objective", "conditional", HOSTING_CODE.rtSafetyViolations, {
656
692
  allocations: m.allocationsInRun ?? 0,
657
693
  syscalls: m.syscallsInRun ?? 0,
658
694
  locks: m.locksInRun ?? 0,
@@ -777,6 +813,60 @@ export function hostingCostReasons(
777
813
  return out;
778
814
  }
779
815
 
816
+ /**
817
+ * What a host's memory budget makes of a plugin. A host with no budget declared reads nothing here. Every cell the host
818
+ * runs is read; the worst stands, so a plugin is never read as fitting on the cell that happened to be measured. A missing
819
+ * figure is `unknown`, never a fit. Under a provisional budget a plugin below the ceiling is `conditional` with a code of its
820
+ * own, since the number it was held to was not measured on that kind of host; one above it is `unsuitable` and says the
821
+ * ceiling is provisional.
822
+ */
823
+ export function hostingMemoryReasons(
824
+ memory: MemoryReadings | undefined,
825
+ profile: HostProfile,
826
+ /** The live rate and quantum, for a host whose budget names no cells of its own; absent, the cell its cost is read at. */
827
+ live?: { readonly rate: number; readonly quantum?: number },
828
+ ): readonly HostingReason[] {
829
+ const budget = profile.cost.memory;
830
+ if (budget === undefined) return [];
831
+ const provisional: CodedRefusalParams = budget.provisional === true ? { provisional: "yes" } : {};
832
+ const cells = budget.cells ?? [{ rate: live?.rate ?? profile.cost.rate, quantum: live?.quantum ?? profile.cost.quantum }];
833
+ const reads = cells.map((c) => ({ ...c, read: memoryWithinBudget(memoryAt(memory, c.rate, c.quantum), budget) }));
834
+ const above = reads.find((r) => r.read.outcome === "above");
835
+ if (above !== undefined && above.read.outcome === "above") {
836
+ return [
837
+ reason("memory", "objective", "unsuitable", HOSTING_CODE.memoryAboveCeiling, {
838
+ rate: above.rate,
839
+ quantum: above.quantum,
840
+ figure: above.read.figure,
841
+ kb: above.read.kb,
842
+ ceilingKb: above.read.ceilingKb,
843
+ ...provisional,
844
+ }),
845
+ ];
846
+ }
847
+ const unmeasured = reads.find((r) => r.read.outcome === "unmeasured");
848
+ if (unmeasured !== undefined && unmeasured.read.outcome === "unmeasured") {
849
+ return [
850
+ reason("memory", "objective", "unknown", HOSTING_CODE.memoryUnmeasured, {
851
+ rate: unmeasured.rate,
852
+ quantum: unmeasured.quantum,
853
+ ...(unmeasured.read.unmeasuredReason === undefined ? {} : { unmeasuredReason: unmeasured.read.unmeasuredReason }),
854
+ }),
855
+ ];
856
+ }
857
+ const first = reads[0]!.read;
858
+ const params: CodedRefusalParams = first.outcome === "within" ? { perInstanceKb: first.perInstanceKb, firstInstanceKb: first.firstInstanceKb, ceilingKb: budget.perInstanceKb, consoleCeilingKb: budget.perConsoleKb } : {};
859
+ return budget.provisional === true
860
+ ? [reason("memory", "objective", "conditional", HOSTING_CODE.memoryWithinProvisionalCeiling, { ...params, ...provisional })]
861
+ : [reason("memory", "objective", "suitable", HOSTING_CODE.memoryWithinCeiling, params)];
862
+ }
863
+
864
+ /** The footprint at a rate and quantum: a grid's cell when the plugin has a grid, else its rate's. */
865
+ function memoryAt(memory: MemoryReadings | undefined, rate: number, quantum: number) {
866
+ if (memory?.perRateQuantum !== undefined) return memory.perRateQuantum[costPointKey(rate, quantum)];
867
+ return memory?.perRate?.[String(rate)];
868
+ }
869
+
780
870
  export function hostingLatencyReasons(
781
871
  descriptor: Pick<QualifiedPlugin, "latency">,
782
872
  handling: HostProfile["latency"] = "compensated",
@@ -924,7 +1014,8 @@ export function clapFeatureReasons(
924
1014
  /**
925
1015
  * The CLAP additions to `rtSafety` (CLAP spec §3): the interposer's counts are read by
926
1016
  * `rtSafetyReasons` exactly as for an LV2; these are the two violations only a CLAP can commit
927
- * — a strict thread-check violation and a `CLAP_PROCESS_ERROR` — each `unsuitable`. A sweep
1017
+ * — a strict thread-check violation (`conditional`: the plugin runs isolated, #196) and a `CLAP_PROCESS_ERROR`
1018
+ * (`unsuitable`). A sweep
928
1019
  * that made no `process()` call measured nothing.
929
1020
  */
930
1021
  export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
@@ -933,7 +1024,7 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
933
1024
  }
934
1025
  const out: HostingReason[] = [];
935
1026
  if (clap.threadViolations > 0) {
936
- out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapThreadViolation, { violations: clap.threadViolations }));
1027
+ out.push(reason("rtSafety", "objective", "conditional", HOSTING_CODE.clapThreadViolation, { violations: clap.threadViolations }));
937
1028
  }
938
1029
  if (clap.processErrors > 0) {
939
1030
  out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: clap.processErrors }));
@@ -945,8 +1036,9 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
945
1036
  * The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a
946
1037
  * plugin that takes no notes; `unknown` when it takes notes and was not played under the
947
1038
  * interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing
948
- * about a voice); `unsuitable` on any allocation, lock or syscall while it was played, or a process
949
- * error; `suitable` on a clean count.
1039
+ * about a voice); `conditional` (isolated in the pin: its own worker, never refused) on any
1040
+ * allocation, lock or syscall while it was played; `unsuitable` on a process error only (a crash);
1041
+ * `suitable` on a clean count.
950
1042
  */
951
1043
  export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] {
952
1044
  if (noteInputs === 0) return [];
@@ -957,7 +1049,7 @@ export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined,
957
1049
  const out: HostingReason[] = [];
958
1050
  if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) {
959
1051
  out.push(
960
- reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyNotesViolations, {
1052
+ reason("rtSafety", "objective", "conditional", HOSTING_CODE.rtSafetyNotesViolations, {
961
1053
  allocations: sweep.allocationsInRun,
962
1054
  syscalls: sweep.syscallsInRun,
963
1055
  locks: sweep.locksInRun,
@@ -1002,6 +1094,7 @@ export function classifyPluginHosting(
1002
1094
  ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.clap?.noteInputs ?? (descriptor.hasMidiIn ? 1 : 0)) : []),
1003
1095
  ...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
1004
1096
  ...hostingCostReasons(descriptor, options, policy),
1097
+ ...hostingMemoryReasons(descriptor.memory, profile, options),
1005
1098
  ...clapLatencyReasons(descriptor.clap),
1006
1099
  ...clapTopologyReasons(descriptor.clap, profile.instruments),
1007
1100
  ]
@@ -1011,6 +1104,7 @@ export function classifyPluginHosting(
1011
1104
  ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.hasMidiIn ? 1 : 0) : []),
1012
1105
  ...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
1013
1106
  ...hostingCostReasons(descriptor, options, policy),
1107
+ ...hostingMemoryReasons(descriptor.memory, profile, options),
1014
1108
  ...hostingLatencyReasons(descriptor, profile.latency),
1015
1109
  ...hostingTopologyReasons(descriptor, profile.instruments),
1016
1110
  ];
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from "./tool-paths.js";
10
10
  export * from "./rating.js";
11
11
  export * from "./host-profiles.js";
12
12
  export * from "./hosting-suitability.js";
13
+ export * from "./memory-budget.js";
13
14
  export * from "./hosting-codes.js";
14
15
  export * from "./loader.js";
15
16
  export * from "./lv2-loader.js";
@@ -30,6 +31,8 @@ export * from "./probe-merge.js";
30
31
  export * from "./judge-cli.js";
31
32
  export * from "./whole-file.js";
32
33
  export * from "./qualify-sync.js";
34
+ export * from "./qualify-rejudge.js";
35
+ export * from "./qualify-adapter-pass.js";
33
36
  export * from "./distributed-records.js";
34
37
  export * from "./records-package.js";
35
38
  export * from "./host-admission.js";
@@ -78,8 +78,10 @@ export const MEASUREMENT_FORMAT_MAJOR = 1;
78
78
  * {@link RtSafetyMeasurement}, {@link FeaturesMeasurement} — which a 1.0 reader skips.
79
79
  * `1.2` added the verdicts per host profile; `1.3` the {@link IntegrityMeasurement} block; `1.4` the notes
80
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;
81
+ * `run()` was counted under ({@link RtSafetyMeasurement.noteSweep}); `1.6` an instance's memory footprint
82
+ * beside the cost reading ({@link CostAtRate.memory}), the soak's readings past warm-up
83
+ * ({@link SoakAtRate.rssAfterWarmupKb}), and a host profile's memory budget ({@link HostBudget.memory}). */
84
+ export const MEASUREMENT_FORMAT_MINOR = 6;
83
85
 
84
86
  /** The version string this build writes, e.g. `"1.0"`. */
85
87
  export const MEASUREMENT_FORMAT_VERSION = `${MEASUREMENT_FORMAT_MAJOR}.${MEASUREMENT_FORMAT_MINOR}`;
@@ -285,6 +287,28 @@ export interface VoicesHeld {
285
287
  readonly capacity?: number;
286
288
  }
287
289
 
290
+ /**
291
+ * 1.6: what one instance holds at this rate: how far the resident set of a process of its own grew from before the
292
+ * plugin loaded, in KB, at each step of its life. Raw readings, never a conclusion: `memory-budget.ts` reads them against
293
+ * a budget. `secondInstanceKb` is what a second instance added once it had done the same, so what one instance costs is
294
+ * told from what every instance shares (a sample library, a wavetable, an IR). `notesHeldKb` and its `basis` are an
295
+ * instrument's, at the notes the cost arm holds.
296
+ *
297
+ * A figure not taken is absent, never 0; `unmeasuredReason` (an {@link ../vocabulary.UNMEASURED_TERMS} code) says why
298
+ * one that should be there is not, and may stand beside the figures that were taken.
299
+ */
300
+ export interface MemoryFootprint {
301
+ readonly afterInstantiateKb?: number;
302
+ readonly afterActivateKb?: number;
303
+ /** Activated, after processing: what a long-running host carries. */
304
+ readonly atRestKb?: number;
305
+ readonly notesHeldKb?: number;
306
+ readonly secondInstanceKb?: number;
307
+ readonly basis?: VoiceBasis;
308
+ readonly unmeasuredReason?: string;
309
+ readonly unmeasuredDetail?: string;
310
+ }
311
+
288
312
  /** One rate's cost reading. Per SAMPLE, so it is independent of the block size. */
289
313
  export interface CostAtRate {
290
314
  readonly nsPerSampleMedian: number;
@@ -303,6 +327,8 @@ export interface CostAtRate {
303
327
  readonly silentOutput?: boolean;
304
328
  /** 1.4: for an instrument, the notes held while it was timed. Absent for an effect. */
305
329
  readonly voices?: VoicesHeld;
330
+ /** 1.6: this instance's memory footprint at the same rate. */
331
+ readonly memory?: MemoryFootprint;
306
332
  }
307
333
 
308
334
  /** Everything one run learned about one plugin's CPU cost. */
@@ -368,6 +394,12 @@ export interface SoakAtRate {
368
394
  readonly rssStartKb?: number;
369
395
  readonly rssMaxKb?: number;
370
396
  readonly rssGrowthKb?: number;
397
+ /** 1.6: the resident set past the settling `firstRmsDbfs` skips, and the audio-seconds to reach it. */
398
+ readonly rssAfterWarmupKb?: number;
399
+ readonly warmupSeconds?: number;
400
+ /** 1.6: the resident set halfway from there to the end, so that growth which stopped is told from growth still climbing. */
401
+ readonly rssMidKb?: number;
402
+ readonly midSeconds?: number;
371
403
  }
372
404
 
373
405
  /** A continuous soak, per rate, with whether the controls were swept while it ran. */
@@ -501,6 +533,22 @@ export interface HostBudget {
501
533
  readonly coreFractionCeiling: number;
502
534
  readonly rate: number;
503
535
  readonly quantum: number;
536
+ /** 1.6: what this host gives its plugins' memory; absent is a host with no budget declared, never a budget of 0. */
537
+ readonly memory?: HostMemoryBudget;
538
+ }
539
+
540
+ /**
541
+ * 1.6: a host's memory budget, and where it is read. `perInstanceKb` bounds what one more instance costs;
542
+ * `perConsoleKb` bounds all the plugins of the host, so the most one plugin may take with what its instances share.
543
+ * `provisional` says the numbers were not measured on that kind of host: a verdict that rests on them says so and does
544
+ * not say the plugin fits. `cells` are the rates and quanta the host runs, each judged, where it runs more than the one
545
+ * its cost is read at.
546
+ */
547
+ export interface HostMemoryBudget {
548
+ readonly perInstanceKb: number;
549
+ readonly perConsoleKb: number;
550
+ readonly provisional?: boolean;
551
+ readonly cells?: readonly { readonly rate: number; readonly quantum: number }[];
504
552
  }
505
553
 
506
554
  /**
@@ -554,6 +602,24 @@ const isPositive = (v: unknown): v is number => typeof v === "number" && Number.
554
602
  * typed; a missing or mistyped one is refused by name, and a host the reader was not told about
555
603
  * is never filled in from a default.
556
604
  */
605
+ /** A budget, `undefined` for none declared, or the words of what is wrong with it. */
606
+ function readHostMemoryBudget(value: unknown): HostMemoryBudget | undefined | string {
607
+ if (value === undefined) return undefined;
608
+ if (!isRecord(value)) return "cost.memory: an object, or absent";
609
+ if (!isPositive(value.perInstanceKb) || !isPositive(value.perConsoleKb)) return "cost.memory: perInstanceKb and perConsoleKb are positive numbers";
610
+ if (value.provisional !== undefined && typeof value.provisional !== "boolean") return "cost.memory.provisional: a boolean, or absent";
611
+ const cells = value.cells;
612
+ if (cells !== undefined && (!Array.isArray(cells) || cells.length === 0 || !cells.every((c) => isRecord(c) && Number.isInteger(c.rate) && Number.isInteger(c.quantum) && isPositive(c.rate) && isPositive(c.quantum)))) {
613
+ return "cost.memory.cells: rates and quanta as positive integers, or absent";
614
+ }
615
+ return {
616
+ perInstanceKb: value.perInstanceKb,
617
+ perConsoleKb: value.perConsoleKb,
618
+ ...optional("provisional", value.provisional as boolean | undefined),
619
+ ...optional("cells", (cells as readonly Record<string, number>[] | undefined)?.map((c) => ({ rate: c.rate!, quantum: c.quantum! }))),
620
+ };
621
+ }
622
+
557
623
  export function readHostProfile(value: unknown): HostProfileRead {
558
624
  if (!isRecord(value)) return { ok: false, problem: "a host profile is a JSON object" };
559
625
  const { id, lv2Features, clapExtensions, instruments, cost, latency, isolation } = value;
@@ -569,6 +635,8 @@ export function readHostProfile(value: unknown): HostProfileRead {
569
635
  if (!Number.isInteger(cost.rate) || !Number.isInteger(cost.quantum)) {
570
636
  return { ok: false, problem: "cost: rate and quantum are integers" };
571
637
  }
638
+ const memory = readHostMemoryBudget(cost.memory);
639
+ if (typeof memory === "string") return { ok: false, problem: memory };
572
640
  if (!(HOST_LATENCY_HANDLING as readonly unknown[]).includes(latency)) {
573
641
  return { ok: false, problem: `latency: one of ${HOST_LATENCY_HANDLING.join(", ")}` };
574
642
  }
@@ -582,7 +650,7 @@ export function readHostProfile(value: unknown): HostProfileRead {
582
650
  lv2Features,
583
651
  clapExtensions,
584
652
  instruments,
585
- cost: { coreFractionCeiling: cost.coreFractionCeiling, rate: cost.rate, quantum: cost.quantum },
653
+ cost: { coreFractionCeiling: cost.coreFractionCeiling, rate: cost.rate, quantum: cost.quantum, ...optional("memory", memory) },
586
654
  latency: latency as HostLatencyHandling,
587
655
  isolation: isolation as HostIsolation,
588
656
  },
@@ -851,6 +919,27 @@ function readLatency(value: unknown): LatencyMeasurement | undefined {
851
919
  };
852
920
  }
853
921
 
922
+ /** A KB figure: finite and never negative. */
923
+ function kilobytes(value: unknown): number | undefined {
924
+ const n = finiteNumber(value);
925
+ return n !== undefined && n >= 0 ? n : undefined;
926
+ }
927
+
928
+ /** A memory footprint, or `undefined` unless it carries at least one figure or a reason. */
929
+ export function readMemoryFootprint(value: unknown): MemoryFootprint | undefined {
930
+ if (!isRecord(value)) return undefined;
931
+ const footprint: MemoryFootprint = {
932
+ ...optional("afterInstantiateKb", kilobytes(value.afterInstantiateKb)),
933
+ ...optional("afterActivateKb", kilobytes(value.afterActivateKb)),
934
+ ...optional("atRestKb", kilobytes(value.atRestKb)),
935
+ ...optional("notesHeldKb", kilobytes(value.notesHeldKb)),
936
+ ...optional("secondInstanceKb", kilobytes(value.secondInstanceKb)),
937
+ ...optional("basis", isVoiceBasis(value.basis) ? value.basis : undefined),
938
+ ...reasonOf(value),
939
+ };
940
+ return Object.keys(footprint).length > 0 ? footprint : undefined;
941
+ }
942
+
854
943
  function readCostAtRate(value: unknown): CostAtRate | undefined {
855
944
  if (!isRecord(value)) return undefined;
856
945
  const nsPerSampleMedian = finiteNumber(value.nsPerSampleMedian);
@@ -875,6 +964,7 @@ function readCostAtRate(value: unknown): CostAtRate | undefined {
875
964
  ...optional("warmupNsPerSampleMedian", finiteNumber(value.warmupNsPerSampleMedian)),
876
965
  ...optional("silentOutput", booleanValue(value.silentOutput)),
877
966
  ...optional("voices", readVoicesHeld(value.voices)),
967
+ ...optional("memory", readMemoryFootprint(value.memory)),
878
968
  };
879
969
  }
880
970
 
@@ -957,6 +1047,10 @@ export function readSoakAtRate(value: unknown): SoakAtRate | undefined {
957
1047
  ...optional("rssStartKb", finiteNumber(value.rssStartKb)),
958
1048
  ...optional("rssMaxKb", finiteNumber(value.rssMaxKb)),
959
1049
  ...optional("rssGrowthKb", finiteNumber(value.rssGrowthKb)),
1050
+ ...optional("rssAfterWarmupKb", kilobytes(value.rssAfterWarmupKb)),
1051
+ ...optional("warmupSeconds", finiteNumber(value.warmupSeconds)),
1052
+ ...optional("rssMidKb", kilobytes(value.rssMidKb)),
1053
+ ...optional("midSeconds", finiteNumber(value.midSeconds)),
960
1054
  };
961
1055
  }
962
1056
 
@@ -38,7 +38,7 @@ export interface InstallationRates {
38
38
  readonly liveRate?: number;
39
39
  /** Rates the active device actually offers. Empty/absent = the console has not probed. */
40
40
  readonly supportedRates?: readonly number[];
41
- /** Rates the operator asked for. Absent = "whatever this installation can use". */
41
+ /** Rates the caller asked for. Absent = "whatever this installation can use". */
42
42
  readonly requestedRates?: readonly number[];
43
43
  }
44
44
 
@@ -47,7 +47,7 @@ export interface RatePlan {
47
47
  /** Rates to measure, ascending. Empty when nothing could be resolved. */
48
48
  readonly rates: readonly number[];
49
49
  /**
50
- * Rates the operator asked for that the installation does not offer. Reported, not
50
+ * Rates the caller asked for that the installation does not offer. Reported, not
51
51
  * measured — a figure at a rate the rig cannot run is a figure nobody will ever use, and
52
52
  * quietly measuring it would make the run longer for nothing.
53
53
  */
@@ -63,7 +63,7 @@ export interface RatePlan {
63
63
  * Resolve the rates a local run should measure at.
64
64
  *
65
65
  * The live rate is always included when known — a console must have figures for the rate it
66
- * is running at, whatever else the operator asked for. Beyond that: the requested rates
66
+ * is running at, whatever else the caller asked for. Beyond that: the requested rates
67
67
  * intersected with what the device offers, or, absent a request, the whole supported list.
68
68
  */
69
69
  export function ratesForInstallation(input: InstallationRates): RatePlan {
@@ -72,6 +72,11 @@ export function gridCells(
72
72
  return unique([...lead.filter((c) => inProduct.has(cellKey(c))), ...product]);
73
73
  }
74
74
 
75
+ /** The cells a host is judged at: the one its cost is read at, then the others its memory budget says it runs. */
76
+ export function hostCells(profile: { readonly cost: GridCell & { readonly memory?: { readonly cells?: readonly GridCell[] } } }): GridCell[] {
77
+ return unique([{ rate: profile.cost.rate, quantum: profile.cost.quantum }, ...(profile.cost.memory?.cells ?? [])]);
78
+ }
79
+
75
80
  /** The first `budget` cells, or every one when the budget is absent or 0. */
76
81
  export function budgetCells(cells: readonly GridCell[], budget: number | undefined): GridCell[] {
77
82
  return budget === undefined || budget === 0 ? [...cells] : cells.slice(0, budget);
@@ -17,9 +17,18 @@ import { MEMORY_BOUND_EXIT_STATUS, MEMORY_LIMIT_RAM_FRACTION } from "./qualify-d
17
17
  /** How the bound is applied: the runner's data limit on the child. */
18
18
  export const MEMORY_BOUND_MECHANISM = "rlimit-data";
19
19
 
20
+ /** How the bound was applied when the runner set it as its cgroup's `memory.max`, which its document says. */
21
+ export const MEMORY_CGROUP_MECHANISM = "cgroup-memory-max";
22
+
20
23
  /** The outcome of a run, and of each of its cells, that outgrew the bound. */
21
24
  export const PROBE_OOM = "probe.oom";
22
25
 
26
+ /** The runner's own outcome for a child the kernel killed at its cgroup's `memory.max`. */
27
+ export const PROBE_MEMORY_LIMIT = "probe.memory-limit";
28
+
29
+ /** Whether an outcome says a run outgrew its memory limit, by either bound. */
30
+ export const isOutOfMemory = (outcome: string): boolean => outcome === PROBE_OOM || outcome === PROBE_MEMORY_LIMIT;
31
+
23
32
  export interface MemoryBound {
24
33
  readonly bytes: number;
25
34
  readonly mechanism: typeof MEMORY_BOUND_MECHANISM;
@@ -50,6 +59,7 @@ export function memoryBoundOf(setting: number | "off" | undefined, ramBytes?: nu
50
59
 
51
60
  /** Whether a run under `bound` ended out of memory: the interposer's exit status, or a SIGKILL that was no deadline's. */
52
61
  export function outOfMemory(output: ProbeOutput, bound: MemoryBound | undefined): boolean {
62
+ if (output.run.outcome === PROBE_MEMORY_LIMIT || output.steps.some((s) => s.outcome === PROBE_MEMORY_LIMIT)) return true;
53
63
  if (bound === undefined) return false;
54
64
  return output.steps.some((s) => s.exitStatus === MEMORY_BOUND_EXIT_STATUS || (s.signal === "SIGKILL" && s.outcome === "probe.crashed"));
55
65
  }
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * What a measured footprint comes to, and whether it fits a host's budget. Pure: the readings come from the measurers, the
5
+ * budget from the host profile, and the verdict's reasons from `hosting-suitability.ts`.
6
+ *
7
+ * Two figures come out of a footprint. `firstInstanceKb` is the most one instance held at any step, with everything its
8
+ * instances share. `perInstanceKb` is what one more instance costs: the second instance's own part and its voices. A host's
9
+ * `perInstanceKb` bounds the second and its `perConsoleKb` the first, so a sampler with a large library shared between
10
+ * instances can fit a small host once and still be read correctly for two.
11
+ *
12
+ * Nothing absent reads as fitting: no figure, or a reason beside the figures, is `unmeasured`.
13
+ */
14
+ import type { HostMemoryBudget, MemoryFootprint } from "./measurement/format.js";
15
+
16
+ /** A plugin's footprints as a verdict reads them: per rate, or per rate and quantum (a probe output's grid). */
17
+ export interface MemoryReadings {
18
+ readonly perRate?: Readonly<Record<string, MemoryFootprint>>;
19
+ readonly perRateQuantum?: Readonly<Record<string, MemoryFootprint>>;
20
+ }
21
+
22
+ /** The most one instance held at any step, with everything its instances share. `undefined` for no figure. */
23
+ export function firstInstanceKb(f: MemoryFootprint): number | undefined {
24
+ const figures = [f.afterInstantiateKb, f.afterActivateKb, f.atRestKb, f.notesHeldKb].filter((v): v is number => v !== undefined);
25
+ return figures.length === 0 ? undefined : Math.max(...figures);
26
+ }
27
+
28
+ /**
29
+ * What one more instance costs: the second instance's own part and the voices of this one. With no second instance
30
+ * measured, the whole of the first, since nothing separates what it shares.
31
+ */
32
+ export function perInstanceKb(f: MemoryFootprint): number | undefined {
33
+ const first = firstInstanceKb(f);
34
+ if (first === undefined) return undefined;
35
+ if (f.secondInstanceKb === undefined) return first;
36
+ const voices = f.notesHeldKb !== undefined && f.atRestKb !== undefined ? Math.max(f.notesHeldKb - f.atRestKb, 0) : 0;
37
+ return f.secondInstanceKb + voices;
38
+ }
39
+
40
+ export type MemoryBudgetRead =
41
+ | { readonly outcome: "within"; readonly perInstanceKb: number; readonly firstInstanceKb: number }
42
+ | { readonly outcome: "above"; readonly figure: "perInstance" | "firstInstance"; readonly kb: number; readonly ceilingKb: number }
43
+ | { readonly outcome: "unmeasured"; readonly unmeasuredReason?: string };
44
+
45
+ /** A footprint against a budget. A missing figure is `"unmeasured"`, never a fit; a figure above a ceiling is above it whatever else is missing. */
46
+ export function memoryWithinBudget(footprint: MemoryFootprint | undefined, budget: HostMemoryBudget): MemoryBudgetRead {
47
+ const first = footprint === undefined ? undefined : firstInstanceKb(footprint);
48
+ const per = footprint === undefined ? undefined : perInstanceKb(footprint);
49
+ if (first !== undefined && first > budget.perConsoleKb) return { outcome: "above", figure: "firstInstance", kb: first, ceilingKb: budget.perConsoleKb };
50
+ if (per !== undefined && per > budget.perInstanceKb) return { outcome: "above", figure: "perInstance", kb: per, ceilingKb: budget.perInstanceKb };
51
+ if (first === undefined || per === undefined || footprint?.unmeasuredReason !== undefined) {
52
+ return { outcome: "unmeasured", ...(footprint?.unmeasuredReason === undefined ? {} : { unmeasuredReason: footprint.unmeasuredReason }) };
53
+ }
54
+ return { outcome: "within", perInstanceKb: per, firstInstanceKb: first };
55
+ }
@@ -24,21 +24,23 @@ import {
24
24
  type ClapRtSafetyReading,
25
25
  type RtSafetyDocument,
26
26
  } from "./clap-qualify.js";
27
- import { gridCostReadings } from "./cost-fields.js";
27
+ import { costPointKey, gridCostReadings } from "./cost-fields.js";
28
28
  import {
29
29
  hostingCostReasons,
30
+ hostingMemoryReasons,
30
31
  noteSweepReasons,
31
32
  rtSafetyReasons,
32
33
  contributedHostingRating,
33
34
  type HostingReason,
34
35
  } from "./hosting-suitability.js";
35
36
  import type { HostProfile } from "./host-profiles.js";
36
- import { admittedHostOf, type HostAdmission } from "./host-admission.js";
37
+ import { runDecides, type HostAdmission } from "./host-admission.js";
37
38
  import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
38
39
  import type { QualifyVerdict } from "./qualify-cli.js";
39
40
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
40
41
  import type { IntegrityMeasurement, VoicesHeld } from "./measurement/format.js";
41
- import { PROBE_OOM } from "./memory-bound.js";
42
+ import { isOutOfMemory } from "./memory-bound.js";
43
+ import type { MemoryFootprint } from "./measurement/format.js";
42
44
 
43
45
  /** Facts of the descriptor, which the probe output repeats none of. */
44
46
  export interface JudgeFacts {
@@ -49,6 +51,11 @@ export interface JudgeFacts {
49
51
  * judged by it as well as by its declaration; one that has none is judged from its declaration alone.
50
52
  */
51
53
  readonly admissions?: Readonly<Record<string, HostAdmission>>;
54
+ /**
55
+ * The run each profile's verdict needs and the figures do not hold, by profile id (`qualify-rejudge.ts`): an `unknown`
56
+ * reason in that verdict, so that a verdict never claims a run nobody made.
57
+ */
58
+ readonly owed?: Readonly<Record<string, RecordReason>>;
52
59
  }
53
60
 
54
61
  /** A sum of one count over the points, or `undefined` when a point lacks it: a missing count is not zero. */
@@ -202,6 +209,13 @@ function decidingOf(reasons: readonly RecordReason[], rating: SuitabilityRating)
202
209
  return [...reasons].sort((a, b) => RATING_SEVERITY[b.rating] - RATING_SEVERITY[a.rating]).find((r) => r.rating === rating);
203
210
  }
204
211
 
212
+ /** The footprints of the cells that ran to their end, as the verdict reads them. */
213
+ function gridMemoryReadings(output: ProbeOutput): Record<string, MemoryFootprint> {
214
+ const out: Record<string, MemoryFootprint> = {};
215
+ for (const p of output.grid) if (p.outcome === PROBE_OK && p.memory !== undefined) out[costPointKey(p.rate, p.quantum)] = p.memory;
216
+ return out;
217
+ }
218
+
205
219
  /** The grid point at a host's own cell, whatever its outcome; `undefined` when the grid does not hold that cell. */
206
220
  function cellOf(output: ProbeOutput, profile: HostProfile): ProbeGridPoint | undefined {
207
221
  return output.grid.find((p) => p.rate === profile.cost.rate && p.quantum === profile.cost.quantum);
@@ -235,10 +249,10 @@ function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"]
235
249
  * first word; a grid point that failed under a run that says `probe.ok` is a document that contradicts
236
250
  * itself, and is read as the failure it records rather than as a pass that skipped the point.
237
251
  */
238
- function failedOutcome(output: ProbeOutput): string | undefined {
252
+ export function failedOutcome(output: ProbeOutput): string | undefined {
239
253
  if (output.run.outcome !== PROBE_OK) return output.run.outcome;
240
254
  // a cell that outgrew the memory bound is a recorded outcome of that cell, judged per host below, not the run's end
241
- return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED && p.outcome !== PROBE_OOM)?.outcome;
255
+ return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED && !isOutOfMemory(p.outcome))?.outcome;
242
256
  }
243
257
 
244
258
  /**
@@ -282,11 +296,15 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
282
296
  }
283
297
  // the plugin did not run within the memory bound at this host's own rate and quantum: not hostable there as measured
284
298
  const cell = cellOf(output, profile);
285
- if (cell?.outcome === PROBE_OOM) {
286
- reasons = [...reasons, { dimension: "memory", rating: "unknown", code: PROBE_OOM, params: { rate: cell.rate, quantum: cell.quantum, ...memoryParams(cell) } }];
299
+ if (cell !== undefined && isOutOfMemory(cell.outcome)) {
300
+ reasons = [...reasons, { dimension: "memory", rating: "unknown", code: cell.outcome, params: { rate: cell.rate, quantum: cell.quantum, ...memoryParams(cell) } }];
301
+ } else {
302
+ // what this host's budget makes of the footprint, every cell it runs: part of the verdict, unlike the cost beside it
303
+ reasons = [...reasons, ...hostingMemoryReasons({ perRateQuantum: gridMemoryReadings(output) }, profile).map(asRecordReason)];
287
304
  }
288
305
  const admission = facts.admissions?.[profile.id];
289
- if (admission !== undefined && admittedHostOf(profile) !== undefined) {
306
+ const ran = admission !== undefined && runDecides(profile, output.plugin.format, admission);
307
+ if (ran && admission !== undefined) {
290
308
  if (!admission.measured || !admission.admitted) {
291
309
  // the host could not be run, or the plugin did not survive it: the run decides, whatever the declaration says
292
310
  const code = admission.code ?? `${admission.host}.not-admitted`;
@@ -300,6 +318,8 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
300
318
  };
301
319
  }
302
320
  }
321
+ const owed = facts.owed?.[profile.id];
322
+ if (owed !== undefined) reasons = [owed, ...reasons];
303
323
  const rating = worstRating(reasons.map((r) => r.rating));
304
324
  const deciding = decidingOf(reasons, rating);
305
325
  return {
@@ -312,7 +332,7 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
312
332
  ...(deciding === undefined ? {} : { deciding: deciding.code }),
313
333
  },
314
334
  hosting: rating === "suitable" && profile.isolation === "in-process" ? "in-process" : "isolated",
315
- arm: admission !== undefined && admittedHostOf(profile) !== undefined ? "run" : "declaration",
335
+ arm: ran ? "run" : "declaration",
316
336
  reasons,
317
337
  cost: costOf(output, profile),
318
338
  };