@fedify/cli 2.3.0-dev.994 → 2.3.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 (83) hide show
  1. package/dist/bench/action.js +469 -0
  2. package/dist/bench/actor/documents.js +39 -0
  3. package/dist/bench/actor/fleet.js +39 -0
  4. package/dist/bench/actor/keys.js +35 -0
  5. package/dist/bench/command.js +72 -0
  6. package/dist/bench/compare/schema.js +16 -0
  7. package/dist/bench/compare.js +667 -0
  8. package/dist/bench/discovery/discover.js +67 -0
  9. package/dist/bench/discovery/probe.js +50 -0
  10. package/dist/bench/load/arrival.js +27 -0
  11. package/dist/bench/load/clock.js +33 -0
  12. package/dist/bench/load/generator.js +145 -0
  13. package/dist/bench/metrics/aggregate.js +64 -0
  14. package/dist/bench/metrics/histogram.js +141 -0
  15. package/dist/bench/metrics/stats-client.js +216 -0
  16. package/dist/bench/mod.js +11 -0
  17. package/dist/bench/render/format.js +46 -0
  18. package/dist/bench/render/index.js +20 -0
  19. package/dist/bench/render/json.js +12 -0
  20. package/dist/bench/render/markdown.js +63 -0
  21. package/dist/bench/render/text.js +75 -0
  22. package/dist/bench/result/build.js +252 -0
  23. package/dist/bench/result/expect/assert.js +74 -0
  24. package/dist/bench/result/expect/evaluate.js +128 -0
  25. package/dist/bench/result/expect/metrics.js +34 -0
  26. package/dist/bench/result/schema.js +365 -0
  27. package/dist/bench/safety/gate.js +62 -0
  28. package/dist/bench/safety/tiers.js +97 -0
  29. package/dist/bench/scenario/coerce.js +24 -0
  30. package/dist/bench/scenario/errors.js +36 -0
  31. package/dist/bench/scenario/load.js +69 -0
  32. package/dist/bench/scenario/normalize.js +125 -0
  33. package/dist/bench/scenario/schema.js +399 -0
  34. package/dist/bench/scenario/units.js +56 -0
  35. package/dist/bench/scenario/validate.js +29 -0
  36. package/dist/bench/scenarios/actor.js +38 -0
  37. package/dist/bench/scenarios/failure.js +363 -0
  38. package/dist/bench/scenarios/fanout.js +261 -0
  39. package/dist/bench/scenarios/inbox.js +147 -0
  40. package/dist/bench/scenarios/mixed.js +244 -0
  41. package/dist/bench/scenarios/object-discovery.js +211 -0
  42. package/dist/bench/scenarios/object.js +54 -0
  43. package/dist/bench/scenarios/read.js +108 -0
  44. package/dist/bench/scenarios/registry.js +39 -0
  45. package/dist/bench/scenarios/runner.js +96 -0
  46. package/dist/bench/scenarios/webfinger.js +44 -0
  47. package/dist/bench/server/synthetic.js +118 -0
  48. package/dist/bench/signing/activity-id.js +18 -0
  49. package/dist/bench/signing/pipeline.js +134 -0
  50. package/dist/bench/signing/signer.js +39 -0
  51. package/dist/bench/template/generate.js +90 -0
  52. package/dist/bench/template/helpers.js +19 -0
  53. package/dist/bench/template/template.js +132 -0
  54. package/dist/cache.js +2 -2
  55. package/dist/commands.js +110 -0
  56. package/dist/config.js +15 -3
  57. package/dist/deno.js +1 -1
  58. package/dist/docloader.js +1 -1
  59. package/dist/generate-vocab/action.js +3 -3
  60. package/dist/generate-vocab/command.js +6 -4
  61. package/dist/imagerenderer.js +3 -3
  62. package/dist/inbox/command.js +6 -4
  63. package/dist/inbox/view.js +1 -1
  64. package/dist/inbox.js +4 -4
  65. package/dist/log.js +2 -2
  66. package/dist/lookup/command.js +121 -0
  67. package/dist/lookup.js +27 -138
  68. package/dist/mod.js +2 -20
  69. package/dist/nodeinfo.js +53 -12
  70. package/dist/options.js +1 -1
  71. package/dist/relay/command.js +6 -4
  72. package/dist/relay.js +3 -3
  73. package/dist/runner.js +70 -45
  74. package/dist/tunnel.js +8 -6
  75. package/dist/utils.js +9 -4
  76. package/dist/webfinger/action.js +1 -1
  77. package/dist/webfinger/command.js +6 -4
  78. package/dist/webfinger/error.js +2 -0
  79. package/dist/webfinger/lib.js +1 -1
  80. package/package.json +28 -23
  81. package/dist/generate-vocab/mod.js +0 -4
  82. package/dist/init/mod.js +0 -3
  83. package/dist/webfinger/mod.js +0 -4
@@ -0,0 +1,67 @@
1
+ import "@js-temporal/polyfill";
2
+ import { getContextLoader, getDocumentLoader } from "../../docloader.js";
3
+ import { convertUrlIfHandle } from "../../webfinger/lib.js";
4
+ import { isActor, lookupObject } from "@fedify/vocab";
5
+ //#region src/bench/discovery/discover.ts
6
+ /**
7
+ * Recipient discovery: resolving a handle or actor URI to the inbox URL a real
8
+ * peer would deliver to.
9
+ *
10
+ * Discovery mirrors how a remote server finds an inbox: WebFinger on a handle
11
+ * yields the actor URI, then the actor document yields its personal `inbox` and
12
+ * its shared inbox endpoint. `lookupObject()` performs the WebFinger step for
13
+ * `acct:` identifiers automatically.
14
+ * @since 2.3.0
15
+ * @module
16
+ */
17
+ /** An error raised when a recipient cannot be discovered. */
18
+ var DiscoveryError = class extends Error {};
19
+ /**
20
+ * Discovers a recipient's inbox URLs from a handle or actor URI.
21
+ * @param recipient A handle (`acct:alice@host` or `@alice@host`) or actor URI.
22
+ * @param options Document/context loaders (use a private-address-allowing
23
+ * loader for loopback targets).
24
+ * @returns The actor URI and its personal and shared inbox URLs.
25
+ * @throws {DiscoveryError} If the recipient does not resolve to an actor with
26
+ * an inbox.
27
+ */
28
+ async function discoverInbox(recipient, options = {}) {
29
+ const identifier = convertUrlIfHandle(recipient);
30
+ const { lookup = lookupObject, allowPrivateAddress } = options;
31
+ const documentLoader = options.documentLoader ?? (allowPrivateAddress ? await getDocumentLoader({ allowPrivateAddress: true }) : void 0);
32
+ const contextLoader = options.contextLoader ?? (allowPrivateAddress ? await getContextLoader({ allowPrivateAddress: true }) : void 0);
33
+ let object;
34
+ try {
35
+ object = await lookup(identifier, {
36
+ documentLoader,
37
+ contextLoader,
38
+ allowPrivateAddress
39
+ });
40
+ } catch (error) {
41
+ throw new DiscoveryError(`Failed to resolve recipient ${recipient}: ${error}`);
42
+ }
43
+ if (!isActor(object)) throw new DiscoveryError(`Recipient ${recipient} did not resolve to an actor.`);
44
+ if (object.inboxId == null) throw new DiscoveryError(`Actor ${recipient} has no inbox.`);
45
+ return {
46
+ actorUri: object.id ?? identifier,
47
+ personalInbox: object.inboxId,
48
+ sharedInbox: object.endpoints?.sharedInbox ?? null
49
+ };
50
+ }
51
+ /**
52
+ * Chooses the inbox URL to deliver to for a scenario's `inbox` mode.
53
+ *
54
+ * `"shared"` (the default) prefers the shared inbox and falls back to the
55
+ * personal one; `"personal"` uses the personal inbox; any other value is an
56
+ * explicit inbox URL that skips discovery selection.
57
+ * @param discovered The discovered inbox URLs.
58
+ * @param mode The scenario's `inbox` value.
59
+ * @returns The inbox URL to deliver to.
60
+ */
61
+ function selectInbox(discovered, mode) {
62
+ if (mode != null && mode !== "shared" && mode !== "personal") return new URL(mode);
63
+ if (mode === "personal") return discovered.personalInbox;
64
+ return discovered.sharedInbox ?? discovered.personalInbox;
65
+ }
66
+ //#endregion
67
+ export { discoverInbox, selectInbox };
@@ -0,0 +1,50 @@
1
+ import "@js-temporal/polyfill";
2
+ //#region src/bench/discovery/probe.ts
3
+ /** The path of the cooperative benchmark stats endpoint. */
4
+ const STATS_PATH = "/.well-known/fedify/bench/stats";
5
+ /**
6
+ * Probes a target for benchmark mode.
7
+ * @param target The target base URL.
8
+ * @param fetchImpl The fetch implementation (overridable for tests).
9
+ * @returns Whether benchmark mode is advertised and the target's Fedify
10
+ * version. Never throws; a failed probe reports `benchmarkMode:
11
+ * false`.
12
+ */
13
+ async function probeBenchmarkMode(target, fetchImpl = fetch) {
14
+ try {
15
+ const response = await fetchImpl(new URL(STATS_PATH, target), {
16
+ headers: { accept: "application/json" },
17
+ redirect: "manual"
18
+ });
19
+ if (!response.ok) return notAdvertised();
20
+ const json = await response.json();
21
+ if (json?.version === 1 && json?.source === "server") return {
22
+ benchmarkMode: true,
23
+ fedifyVersion: extractFedifyVersion(json)
24
+ };
25
+ return notAdvertised();
26
+ } catch {
27
+ return notAdvertised();
28
+ }
29
+ }
30
+ function notAdvertised() {
31
+ return {
32
+ benchmarkMode: false,
33
+ fedifyVersion: null
34
+ };
35
+ }
36
+ function extractFedifyVersion(json) {
37
+ try {
38
+ const scopes = Array.isArray(json.scopeMetrics) ? json.scopeMetrics : [];
39
+ for (const entry of scopes) {
40
+ if (entry == null || typeof entry !== "object") continue;
41
+ const descriptor = entry.scope;
42
+ if (descriptor == null || typeof descriptor !== "object") continue;
43
+ const { name, version } = descriptor;
44
+ if (name === "@fedify/fedify") return typeof version === "string" ? version : null;
45
+ }
46
+ } catch {}
47
+ return null;
48
+ }
49
+ //#endregion
50
+ export { STATS_PATH, probeBenchmarkMode };
@@ -0,0 +1,27 @@
1
+ import "@js-temporal/polyfill";
2
+ //#region src/bench/load/arrival.ts
3
+ /**
4
+ * Lazily yields the scheduled arrival offsets (milliseconds from the start) for
5
+ * a load run. Yielding rather than materializing keeps memory flat for long,
6
+ * high-rate runs.
7
+ * @param options The scheduling options.
8
+ * @yields Arrival offsets within `[0, durationMs)`, in increasing order.
9
+ */
10
+ function* scheduleArrivals(options) {
11
+ const { ratePerSec, durationMs, arrival } = options;
12
+ if (ratePerSec <= 0 || durationMs <= 0) return;
13
+ const meanGapMs = 1e3 / ratePerSec;
14
+ if (arrival === "constant") {
15
+ for (let t = 0; t < durationMs; t += meanGapMs) yield t;
16
+ return;
17
+ }
18
+ const rng = options.rng ?? Math.random;
19
+ let t = 0;
20
+ for (;;) {
21
+ t += -Math.log(1 - rng()) * meanGapMs;
22
+ if (t >= durationMs) break;
23
+ yield t;
24
+ }
25
+ }
26
+ //#endregion
27
+ export { scheduleArrivals };
@@ -0,0 +1,33 @@
1
+ import "@js-temporal/polyfill";
2
+ //#region src/bench/load/clock.ts
3
+ /** Returns a clock backed by `performance.now()` and `setTimeout`. */
4
+ function systemClock() {
5
+ return {
6
+ now: () => performance.now(),
7
+ sleepUntil(timeMs, signal) {
8
+ if (signal?.aborted) return Promise.reject(abortReason(signal));
9
+ const remaining = timeMs - performance.now();
10
+ if (remaining <= 0) return Promise.resolve();
11
+ return new Promise((resolve, reject) => {
12
+ const timer = setTimeout(() => {
13
+ cleanup();
14
+ resolve();
15
+ }, remaining);
16
+ const onAbort = () => {
17
+ clearTimeout(timer);
18
+ cleanup();
19
+ reject(abortReason(signal));
20
+ };
21
+ const cleanup = () => {
22
+ signal?.removeEventListener("abort", onAbort);
23
+ };
24
+ signal?.addEventListener("abort", onAbort, { once: true });
25
+ });
26
+ }
27
+ };
28
+ }
29
+ function abortReason(signal) {
30
+ return signal.reason ?? /* @__PURE__ */ new Error("Operation aborted.");
31
+ }
32
+ //#endregion
33
+ export { systemClock };
@@ -0,0 +1,145 @@
1
+ import "@js-temporal/polyfill";
2
+ import { scheduleArrivals } from "./arrival.js";
3
+ import { systemClock } from "./clock.js";
4
+ //#region src/bench/load/generator.ts
5
+ /**
6
+ * Runs a load plan against a send function.
7
+ * @param plan The load plan.
8
+ * @param send The function that performs one send.
9
+ * @param clock The clock (overridable for tests); defaults to the system clock.
10
+ * @returns The recorded samples and run metadata.
11
+ */
12
+ function runLoad(plan, send, clock = systemClock(), signal) {
13
+ return plan.load.kind === "open" ? runOpenLoop(plan, plan.load, send, clock, signal) : runClosedLoop(plan, plan.load, send, clock, signal);
14
+ }
15
+ async function runOpenLoop(plan, load, send, clock, signal) {
16
+ const arrivals = scheduleArrivals({
17
+ ratePerSec: load.ratePerSec,
18
+ durationMs: plan.durationMs,
19
+ arrival: load.arrival,
20
+ rng: plan.rng
21
+ });
22
+ const samples = [];
23
+ const slots = createSemaphore(load.maxInFlight);
24
+ let saturated = false;
25
+ const start = clock.now();
26
+ const active = /* @__PURE__ */ new Set();
27
+ for (const offset of arrivals) {
28
+ throwIfAborted(signal);
29
+ await clock.sleepUntil(start + offset, signal);
30
+ if (await slots.acquire(signal)) saturated = true;
31
+ if (signal?.aborted) {
32
+ slots.release();
33
+ throw abortReason(signal);
34
+ }
35
+ const dispatched = dispatch(send, offset, start, plan.warmupMs, clock, samples).finally(() => {
36
+ slots.release();
37
+ active.delete(dispatched);
38
+ });
39
+ active.add(dispatched);
40
+ }
41
+ await Promise.all(active);
42
+ return {
43
+ samples,
44
+ saturated,
45
+ wallDurationMs: clock.now() - start
46
+ };
47
+ }
48
+ async function runClosedLoop(plan, load, send, clock, signal) {
49
+ const samples = [];
50
+ const slots = createSemaphore(load.maxInFlight);
51
+ let saturated = false;
52
+ const start = clock.now();
53
+ const deadline = start + plan.durationMs;
54
+ async function worker() {
55
+ while (clock.now() < deadline) {
56
+ throwIfAborted(signal);
57
+ if (await slots.acquire(signal)) saturated = true;
58
+ if (signal?.aborted) {
59
+ slots.release();
60
+ throw abortReason(signal);
61
+ }
62
+ if (clock.now() >= deadline) {
63
+ slots.release();
64
+ break;
65
+ }
66
+ const offset = clock.now() - start;
67
+ try {
68
+ await dispatch(send, offset, start, plan.warmupMs, clock, samples);
69
+ } finally {
70
+ slots.release();
71
+ }
72
+ }
73
+ }
74
+ await Promise.all(Array.from({ length: load.concurrency }, () => worker()));
75
+ return {
76
+ samples,
77
+ saturated,
78
+ wallDurationMs: clock.now() - start
79
+ };
80
+ }
81
+ async function dispatch(send, offset, start, warmupMs, clock, samples) {
82
+ let outcome;
83
+ try {
84
+ outcome = await send(offset);
85
+ } catch (error) {
86
+ outcome = {
87
+ ok: false,
88
+ errorKind: "exception",
89
+ reason: String(error)
90
+ };
91
+ }
92
+ samples.push({
93
+ scheduledAtMs: offset,
94
+ latencyMs: clock.now() - (start + offset),
95
+ warmup: offset < warmupMs,
96
+ outcome
97
+ });
98
+ }
99
+ function createSemaphore(max) {
100
+ if (max == null) return {
101
+ acquire: () => Promise.resolve(false),
102
+ release: () => {}
103
+ };
104
+ let count = 0;
105
+ const queue = [];
106
+ return {
107
+ acquire(signal) {
108
+ throwIfAborted(signal);
109
+ if (count < max) {
110
+ count++;
111
+ return Promise.resolve(false);
112
+ }
113
+ return new Promise((resolve, reject) => {
114
+ const waiter = () => {
115
+ cleanup();
116
+ resolve(true);
117
+ };
118
+ const onAbort = () => {
119
+ const index = queue.indexOf(waiter);
120
+ if (index >= 0) queue.splice(index, 1);
121
+ cleanup();
122
+ reject(abortReason(signal));
123
+ };
124
+ const cleanup = () => {
125
+ signal?.removeEventListener("abort", onAbort);
126
+ };
127
+ signal?.addEventListener("abort", onAbort, { once: true });
128
+ queue.push(waiter);
129
+ });
130
+ },
131
+ release() {
132
+ const next = queue.shift();
133
+ if (next != null) next();
134
+ else count--;
135
+ }
136
+ };
137
+ }
138
+ function throwIfAborted(signal) {
139
+ if (signal?.aborted) throw abortReason(signal);
140
+ }
141
+ function abortReason(signal) {
142
+ return signal.reason ?? /* @__PURE__ */ new Error("Benchmark load aborted.");
143
+ }
144
+ //#endregion
145
+ export { runLoad };
@@ -0,0 +1,64 @@
1
+ import "@js-temporal/polyfill";
2
+ import { LogLinearHistogram } from "./histogram.js";
3
+ //#region src/bench/metrics/aggregate.ts
4
+ /**
5
+ * Aggregates samples into the client side of a scenario measurement (the
6
+ * `server` field is left `null` for the runner to fill from the stats endpoint).
7
+ * @param samples The raw samples from the load generator.
8
+ * @param options Aggregation options.
9
+ * @returns The client-side scenario measurement.
10
+ */
11
+ function aggregateSamples(samples, options) {
12
+ const measured = samples.filter((s) => !s.warmup);
13
+ const histogram = new LogLinearHistogram();
14
+ const errorCounts = /* @__PURE__ */ new Map();
15
+ let ok = 0;
16
+ for (const sample of measured) {
17
+ histogram.record(sample.latencyMs);
18
+ if (sample.outcome.ok) ok++;
19
+ else bucketError(errorCounts, sample);
20
+ }
21
+ const total = measured.length;
22
+ const requests = {
23
+ total,
24
+ ok,
25
+ failed: total - ok,
26
+ successRate: total === 0 ? 1 : ok / total
27
+ };
28
+ const windowSec = Math.max(options.measuredWindowMs, 1) / 1e3;
29
+ const client = { latencyMs: {
30
+ p50: histogram.percentile(50),
31
+ p95: histogram.percentile(95),
32
+ p99: histogram.percentile(99),
33
+ mean: histogram.mean,
34
+ max: histogram.max
35
+ } };
36
+ const errors = [...errorCounts.values()].sort((a, b) => b.count - a.count);
37
+ return {
38
+ requests,
39
+ throughputPerSec: total / windowSec,
40
+ client,
41
+ server: null,
42
+ errors,
43
+ ...options.includeHistogram ? { histogram: histogram.toJSON() } : {}
44
+ };
45
+ }
46
+ function bucketError(buckets, sample) {
47
+ const { status, errorKind, reason } = sample.outcome;
48
+ const kind = errorKind ?? (status != null ? "http" : "error");
49
+ const reasonText = reason ?? (status != null ? `status_${status}` : "error");
50
+ const key = `${kind}|${status ?? ""}|${reasonText}`;
51
+ const existing = buckets.get(key);
52
+ if (existing != null) buckets.set(key, {
53
+ ...existing,
54
+ count: existing.count + 1
55
+ });
56
+ else buckets.set(key, {
57
+ kind,
58
+ ...status != null ? { status } : {},
59
+ reason: reasonText,
60
+ count: 1
61
+ });
62
+ }
63
+ //#endregion
64
+ export { aggregateSamples };
@@ -0,0 +1,141 @@
1
+ import "@js-temporal/polyfill";
2
+ /**
3
+ * A sparse log-linear histogram.
4
+ * @since 2.3.0
5
+ */
6
+ var LogLinearHistogram = class LogLinearHistogram {
7
+ subBucketCount;
8
+ #buckets = /* @__PURE__ */ new Map();
9
+ #count = 0;
10
+ #zeroCount = 0;
11
+ #sum = 0;
12
+ #min = Number.POSITIVE_INFINITY;
13
+ #max = Number.NEGATIVE_INFINITY;
14
+ constructor(options = {}) {
15
+ const subBucketCount = options.subBucketCount ?? 128;
16
+ if (!Number.isInteger(subBucketCount) || subBucketCount < 1) throw new RangeError(`subBucketCount must be a positive integer; got ${subBucketCount}.`);
17
+ this.subBucketCount = subBucketCount;
18
+ }
19
+ /** The total number of recorded samples, including zeros. */
20
+ get count() {
21
+ return this.#count;
22
+ }
23
+ /** The smallest recorded value, or `0` when the histogram is empty. */
24
+ get min() {
25
+ return this.#count === 0 ? 0 : this.#min;
26
+ }
27
+ /** The largest recorded value, or `0` when the histogram is empty. */
28
+ get max() {
29
+ return this.#count === 0 ? 0 : this.#max;
30
+ }
31
+ /** The arithmetic mean of all recorded values, or `0` when empty. */
32
+ get mean() {
33
+ return this.#count === 0 ? 0 : this.#sum / this.#count;
34
+ }
35
+ /** The exact sum of all recorded values. */
36
+ get sum() {
37
+ return this.#sum;
38
+ }
39
+ /**
40
+ * Records a single sample.
41
+ * @param value The value to record. Non-finite values are ignored; any
42
+ * non-positive value (negatives, `0`, and `-0`) is normalized to
43
+ * `0` and recorded in the zero bucket, since latency samples are
44
+ * never negative.
45
+ */
46
+ record(value) {
47
+ if (!Number.isFinite(value)) return;
48
+ const v = value <= 0 ? 0 : value;
49
+ this.#count++;
50
+ this.#sum += v;
51
+ if (v < this.#min) this.#min = v;
52
+ if (v > this.#max) this.#max = v;
53
+ if (v === 0) {
54
+ this.#zeroCount++;
55
+ return;
56
+ }
57
+ const index = this.#indexOf(v);
58
+ this.#buckets.set(index, (this.#buckets.get(index) ?? 0) + 1);
59
+ }
60
+ /**
61
+ * Computes an estimated percentile.
62
+ * @param p The percentile to compute, between 0 and 100 inclusive.
63
+ * @returns The estimated value at the given percentile, or `0` when the
64
+ * histogram is empty.
65
+ */
66
+ percentile(p) {
67
+ if (this.#count === 0) return 0;
68
+ if (p <= 0) return this.#min;
69
+ if (p >= 100) return this.#max;
70
+ const target = Math.ceil(p / 100 * this.#count);
71
+ let accumulated = this.#zeroCount;
72
+ if (accumulated >= target) return 0;
73
+ const indices = [...this.#buckets.keys()].sort((a, b) => a - b);
74
+ for (const index of indices) {
75
+ accumulated += this.#buckets.get(index);
76
+ if (accumulated >= target) return this.#clamp(this.#representativeValue(index));
77
+ }
78
+ return this.#max;
79
+ }
80
+ /**
81
+ * Merges another histogram into this one. Both histograms must use the same
82
+ * {@link LogLinearHistogram.subBucketCount}.
83
+ * @param other The histogram to merge in.
84
+ */
85
+ merge(other) {
86
+ if (other.subBucketCount !== this.subBucketCount) throw new TypeError(`Cannot merge histograms with different subBucketCount (${this.subBucketCount} vs ${other.subBucketCount}).`);
87
+ if (other.#count === 0) return;
88
+ for (const [index, count] of other.#buckets) this.#buckets.set(index, (this.#buckets.get(index) ?? 0) + count);
89
+ this.#count += other.#count;
90
+ this.#zeroCount += other.#zeroCount;
91
+ this.#sum += other.#sum;
92
+ if (other.#min < this.#min) this.#min = other.#min;
93
+ if (other.#max > this.#max) this.#max = other.#max;
94
+ }
95
+ /** Serializes the histogram to a plain JSON-compatible object. */
96
+ toJSON() {
97
+ const indices = [...this.#buckets.keys()].sort((a, b) => a - b);
98
+ return {
99
+ version: 1,
100
+ subBucketCount: this.subBucketCount,
101
+ count: this.#count,
102
+ zeroCount: this.#zeroCount,
103
+ min: this.min,
104
+ max: this.max,
105
+ sum: this.#sum,
106
+ indices,
107
+ counts: indices.map((index) => this.#buckets.get(index))
108
+ };
109
+ }
110
+ /** Reconstructs a histogram from its serialized form. */
111
+ static fromJSON(json) {
112
+ if (json.indices.length !== json.counts.length) throw new TypeError("Serialized histogram indices and counts must have equal length.");
113
+ const histogram = new LogLinearHistogram({ subBucketCount: json.subBucketCount });
114
+ for (let i = 0; i < json.indices.length; i++) histogram.#buckets.set(json.indices[i], json.counts[i]);
115
+ histogram.#count = json.count;
116
+ histogram.#zeroCount = json.zeroCount;
117
+ histogram.#sum = json.sum;
118
+ histogram.#min = json.count === 0 ? Number.POSITIVE_INFINITY : json.min;
119
+ histogram.#max = json.count === 0 ? Number.NEGATIVE_INFINITY : json.max;
120
+ return histogram;
121
+ }
122
+ #indexOf(value) {
123
+ const octave = Math.floor(Math.log2(value));
124
+ let sub = Math.floor((value / 2 ** octave - 1) * this.subBucketCount);
125
+ if (sub < 0) sub = 0;
126
+ else if (sub >= this.subBucketCount) sub = this.subBucketCount - 1;
127
+ return octave * this.subBucketCount + sub;
128
+ }
129
+ #representativeValue(index) {
130
+ const octave = Math.floor(index / this.subBucketCount);
131
+ const sub = index - octave * this.subBucketCount;
132
+ return 2 ** octave * (1 + (sub + .5) / this.subBucketCount);
133
+ }
134
+ #clamp(value) {
135
+ if (value < this.#min) return this.#min;
136
+ if (value > this.#max) return this.#max;
137
+ return value;
138
+ }
139
+ };
140
+ //#endregion
141
+ export { LogLinearHistogram };