@homie-rocks/studio 0.18.2 → 0.19.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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * WHETHER A CHANGE MADE A GAME FASTER, OR ONLY LOOKS LIKE IT (`homie-studio perf compare`).
3
+ *
4
+ * Two builds are measured in turns (before, after, after, before, ...), several runs each, and every run gives one
5
+ * number per metric (a run's 95th-percentile frame time, say). A browser's numbers move from run to run on their own:
6
+ * another app wakes up, a garbage collection lands in the window, the bots wander somewhere busier. So a difference
7
+ * between two medians means nothing by itself. A change counts as better only when all three hold:
8
+ *
9
+ * - it is unlikely to be chance: a permutation test on the runs' ranks (Mann-Whitney U, exact for the small run
10
+ * counts used here), one-sided, p < 0.05;
11
+ * - the 95% bootstrap interval of the change in medians stays on the better side of zero;
12
+ * - it is big enough to matter: at least `min` (3%) better at the median.
13
+ *
14
+ * A guard (another metric that must not get worse) is judged the same way, stricter (p < 0.01, at least 5% worse),
15
+ * because a handful of guards checked at 0.05 would flag a regression by chance on most changes. A metric that never
16
+ * varies (bytes of a build) has no noise to test: any change of at least 1% counts.
17
+ *
18
+ * PAIRS. A shared computer drifts: measured on an Apple M4 while other work ran, the main thread per frame rose from
19
+ * 0.88 to 1.25 ms over five runs of the SAME build as the load average went from 4 to 7.6, host and replica together.
20
+ * When the runs were taken side by side (`try` measures before, after, after, before, … and labels each neighbouring
21
+ * pair), the test is on the pairs instead: each after-run against the before-run next to it, as a ratio, so a drift
22
+ * that hits both cancels. Wilcoxon signed-rank, exact: with 6 pairs, p < 0.05 needs the after-run to win at least 5
23
+ * of the 6 pairs (and lose only the closest one). The change is the median ratio; its interval a bootstrap over pairs.
24
+ *
25
+ * Everything here is lower-is-better (milliseconds, bytes, messages); nothing reads a clock or the network.
26
+ */
27
+
28
+ /** The q-quantile (0..1) of numbers, by linear interpolation between order statistics. null for none. */
29
+ export function quantile(values, q) {
30
+ const xs = values.filter(Number.isFinite).sort((a, b) => a - b);
31
+ if (!xs.length) return null;
32
+ const at = (xs.length - 1) * q;
33
+ const lo = Math.floor(at);
34
+ const hi = Math.ceil(at);
35
+ return xs[lo] + (xs[hi] - xs[lo]) * (at - lo);
36
+ }
37
+
38
+ export const median = (values) => quantile(values, 0.5);
39
+
40
+ /** A small summary of numbers: n, median, the middle half, min, max, and the spread as a share of the median. */
41
+ export function summarize(values) {
42
+ const xs = values.filter(Number.isFinite);
43
+ if (!xs.length) return { n: 0, median: null, q1: null, q3: null, min: null, max: null, spread: null };
44
+ const m = median(xs);
45
+ const q1 = quantile(xs, 0.25);
46
+ const q3 = quantile(xs, 0.75);
47
+ return { n: xs.length, median: round(m), q1: round(q1), q3: round(q3), min: round(Math.min(...xs)), max: round(Math.max(...xs)), spread: m ? round((q3 - q1) / Math.abs(m), 4) : null };
48
+ }
49
+
50
+ export const round = (x, digits = 3) => (Number.isFinite(x) ? Number(x.toFixed(digits)) : null);
51
+
52
+ /** Midranks of a joined list (ties share the average of their ranks), 1-based. */
53
+ function ranks(values) {
54
+ const order = values.map((v, i) => [v, i]).sort((a, b) => a[0] - b[0]);
55
+ const r = new Array(values.length);
56
+ for (let i = 0; i < order.length;) {
57
+ let j = i;
58
+ while (j + 1 < order.length && order[j + 1][0] === order[i][0]) j++;
59
+ const mid = (i + j) / 2 + 1;
60
+ for (let k = i; k <= j; k++) r[order[k][1]] = mid;
61
+ i = j + 1;
62
+ }
63
+ return r;
64
+ }
65
+
66
+ /**
67
+ * Mann-Whitney U as an exact permutation test on rank sums: every way of choosing which runs were "after" is equally
68
+ * likely when the change did nothing, so p is the share of those choices whose "after" ranks are as low (`less`) or as
69
+ * high (`greater`) as the ones measured. Exact up to 200,000 choices (10 + 10 runs is 184,756), the normal
70
+ * approximation with a tie correction past that.
71
+ */
72
+ export function rankTest(before, after) {
73
+ const a = before.filter(Number.isFinite);
74
+ const b = after.filter(Number.isFinite);
75
+ const n1 = a.length;
76
+ const n2 = b.length;
77
+ if (!n1 || !n2) return { n1, n2, u: null, less: null, greater: null, twoSided: null, exact: false };
78
+ const r = ranks([...a, ...b]);
79
+ const observed = r.slice(n1).reduce((s, x) => s + x, 0);
80
+ const u = observed - (n2 * (n2 + 1)) / 2;
81
+ const total = binom(n1 + n2, n2);
82
+ const eps = 1e-9;
83
+ if (total <= 200_000) {
84
+ let le = 0;
85
+ let ge = 0;
86
+ let all = 0;
87
+ const pick = (start, left, sum) => {
88
+ if (left === 0) { all++; if (sum <= observed + eps) le++; if (sum >= observed - eps) ge++; return; }
89
+ for (let i = start; i <= r.length - left; i++) pick(i + 1, left - 1, sum + r[i]);
90
+ };
91
+ pick(0, n2, 0);
92
+ const less = le / all;
93
+ const greater = ge / all;
94
+ return { n1, n2, u, less: round(less, 5), greater: round(greater, 5), twoSided: round(Math.min(1, 2 * Math.min(less, greater)), 5), exact: true };
95
+ }
96
+ const n = n1 + n2;
97
+ const mean = (n2 * (n + 1)) / 2;
98
+ const counts = new Map();
99
+ for (const x of r) counts.set(x, (counts.get(x) ?? 0) + 1);
100
+ const ties = [...counts.values()].reduce((s, t) => s + (t ** 3 - t), 0);
101
+ const sd = Math.sqrt(((n1 * n2) / 12) * ((n + 1) - ties / (n * (n - 1))));
102
+ const z = sd ? (observed - mean) / sd : 0;
103
+ const less = normalCdf(z + 0.5 / (sd || 1));
104
+ const greater = 1 - normalCdf(z - 0.5 / (sd || 1));
105
+ return { n1, n2, u, less: round(less, 5), greater: round(greater, 5), twoSided: round(Math.min(1, 2 * Math.min(less, greater)), 5), exact: false };
106
+ }
107
+
108
+ function binom(n, k) {
109
+ let c = 1;
110
+ for (let i = 1; i <= k; i++) c = (c * (n - k + i)) / i;
111
+ return Math.round(c);
112
+ }
113
+
114
+ function normalCdf(z) {
115
+ // Abramowitz and Stegun 7.1.26, |error| < 1.5e-7.
116
+ const t = 1 / (1 + 0.3275911 * Math.abs(z) / Math.SQRT2);
117
+ const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-(z * z) / 2);
118
+ return z >= 0 ? (1 + y) / 2 : (1 - y) / 2;
119
+ }
120
+
121
+ /** A seeded random number generator (mulberry32): the same runs always give the same interval. */
122
+ export function seeded(seed = 1) {
123
+ let s = seed >>> 0;
124
+ return () => {
125
+ s = (s + 0x6d2b79f5) >>> 0;
126
+ let t = s;
127
+ t = Math.imul(t ^ (t >>> 15), t | 1);
128
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
129
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
130
+ };
131
+ }
132
+
133
+ /**
134
+ * The 95% percentile-bootstrap interval of the relative change in medians, (after - before) / before: resample each
135
+ * side's runs with replacement 4,000 times. Seeded, so the same numbers give the same interval.
136
+ */
137
+ export function bootstrapChange(before, after, { iterations = 4000, seed = 7 } = {}) {
138
+ const a = before.filter(Number.isFinite);
139
+ const b = after.filter(Number.isFinite);
140
+ if (!a.length || !b.length) return null;
141
+ const rnd = seeded(seed);
142
+ const draw = (xs) => { const out = new Array(xs.length); for (let i = 0; i < xs.length; i++) out[i] = xs[Math.floor(rnd() * xs.length)]; return out; };
143
+ const changes = [];
144
+ for (let i = 0; i < iterations; i++) {
145
+ const ma = median(draw(a));
146
+ const mb = median(draw(b));
147
+ if (ma) changes.push((mb - ma) / Math.abs(ma));
148
+ }
149
+ if (!changes.length) return null;
150
+ return [round(quantile(changes, 0.025), 4), round(quantile(changes, 0.975), 4)];
151
+ }
152
+
153
+ /**
154
+ * One metric, before against after (lower is better). Returns the medians, the change, p, the interval and a verdict:
155
+ * `better`, `worse` or `same` (within the noise), with `why` in words. `min` is the smallest change that counts as
156
+ * better (0.03 = 3%); `worseMin` and `worseAlpha` the stricter bar for calling it worse.
157
+ */
158
+ export function judge(before, after, { min = 0.03, alpha = 0.05, worseMin = 0.05, worseAlpha = 0.01, unit = '' } = {}) {
159
+ const a = before.filter(Number.isFinite);
160
+ const b = after.filter(Number.isFinite);
161
+ const sa = summarize(a);
162
+ const sb = summarize(b);
163
+ const base = { before: sa, after: sb, change: null, p: null, pWorse: null, ci: null, verdict: 'unknown', why: null };
164
+ if (!a.length || !b.length) return { ...base, why: `not measured on both sides (${a.length} before, ${b.length} after)` };
165
+ const change = sa.median ? (sb.median - sa.median) / Math.abs(sa.median) : (sb.median === sa.median ? 0 : null);
166
+ const fixed = sa.min === sa.max && sb.min === sb.max;
167
+ const pct = (x) => `${x > 0 ? '+' : ''}${(x * 100).toFixed(1)}%`;
168
+ const say = (x) => `${round(x, 2)}${unit}`;
169
+ if (change === null) return { ...base, change: null, why: 'the before median is 0, so a relative change has no meaning' };
170
+ if (fixed) {
171
+ // Nothing to test: the same number every run (a build's bytes). Any real difference is the change itself.
172
+ const verdict = change <= -0.01 ? 'better' : change >= 0.01 ? 'worse' : 'same';
173
+ return { ...base, change: round(change, 4), verdict, exact: true, why: `${say(sa.median)} → ${say(sb.median)} (${pct(change)}, the same every run)` };
174
+ }
175
+ const t = rankTest(a, b);
176
+ const ci = bootstrapChange(a, b);
177
+ const res = { ...base, change: round(change, 4), p: t.less, pWorse: t.greater, ci, n: [t.n1, t.n2] };
178
+ const head = `${say(sa.median)} → ${say(sb.median)} (${pct(change)}; 95% interval ${ci ? `${pct(ci[0])} to ${pct(ci[1])}` : 'n/a'}; p = ${t.less} better, ${t.greater} worse; ${t.n1} + ${t.n2} runs)`;
179
+ if (t.less < alpha && ci && ci[1] < 0 && change <= -min) return { ...res, verdict: 'better', why: head };
180
+ if (t.greater < worseAlpha && ci && ci[0] > 0 && change >= worseMin) return { ...res, verdict: 'worse', why: head };
181
+ const reasons = [];
182
+ if (change > -min) reasons.push(`smaller than the ${Math.round(min * 100)}% that counts`);
183
+ if (t.less >= alpha) reasons.push(`p ${t.less} is not under ${alpha}`);
184
+ if (ci && ci[1] >= 0) reasons.push('the interval reaches zero');
185
+ return { ...res, verdict: 'same', why: `${head}: within the noise (${reasons.join('; ') || 'not clearly either way'})` };
186
+ }
187
+
188
+ /**
189
+ * Wilcoxon signed-rank test on paired differences (after - before, or their log ratios), exact for up to 20 pairs
190
+ * (every sign assignment), the normal approximation past that. Zeros are dropped. `less`: p that the after side is
191
+ * lower by chance; `greater`: higher.
192
+ */
193
+ export function signedRankTest(diffs) {
194
+ const d = diffs.filter((x) => Number.isFinite(x) && x !== 0);
195
+ const n = d.length;
196
+ if (!n) return { n: 0, wins: 0, less: 1, greater: 1, exact: true };
197
+ const r = ranks(d.map(Math.abs));
198
+ const wPlus = d.reduce((s, x, i) => s + (x > 0 ? r[i] : 0), 0);
199
+ const wins = d.filter((x) => x < 0).length;
200
+ const eps = 1e-9;
201
+ if (n <= 20) {
202
+ let le = 0;
203
+ let ge = 0;
204
+ const total = 2 ** n;
205
+ for (let mask = 0; mask < total; mask++) {
206
+ let w = 0;
207
+ for (let i = 0; i < n; i++) if (mask & (1 << i)) w += r[i];
208
+ if (w <= wPlus + eps) le++;
209
+ if (w >= wPlus - eps) ge++;
210
+ }
211
+ return { n, wins, less: round(le / total, 5), greater: round(ge / total, 5), exact: true };
212
+ }
213
+ const mean = (n * (n + 1)) / 4;
214
+ const sd = Math.sqrt((n * (n + 1) * (2 * n + 1)) / 24);
215
+ return { n, wins, less: round(normalCdf((wPlus + 0.5 - mean) / sd), 5), greater: round(1 - normalCdf((wPlus - 0.5 - mean) / sd), 5), exact: false };
216
+ }
217
+
218
+ /**
219
+ * One metric from runs taken in pairs (before[k] beside after[k]); lower is better. The same verdicts and bars as
220
+ * judge(), on the pairs' ratios.
221
+ */
222
+ export function judgePaired(before, after, { min = 0.03, alpha = 0.05, worseMin = 0.05, worseAlpha = 0.01, unit = '' } = {}) {
223
+ const pairs = before.map((a, i) => [a, after[i]]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && a > 0 && b > 0);
224
+ const sa = summarize(pairs.map(([a]) => a));
225
+ const sb = summarize(pairs.map(([, b]) => b));
226
+ const base = { before: sa, after: sb, change: null, p: null, pWorse: null, ci: null, verdict: 'unknown', why: null, paired: true };
227
+ if (pairs.length < 3) return { ...base, why: `too few pairs measured on both sides (${pairs.length})` };
228
+ const logs = pairs.map(([a, b]) => Math.log(b / a));
229
+ const pct = (x) => `${x > 0 ? '+' : ''}${(x * 100).toFixed(1)}%`;
230
+ const say = (x) => `${round(x, 2)}${unit}`;
231
+ if (logs.every((x) => x === 0)) return { ...base, change: 0, p: 1, pWorse: 1, verdict: 'same', why: `${say(sa.median)} → ${say(sb.median)}: identical in every pair` };
232
+ const change = Math.exp(median(logs)) - 1;
233
+ const t = signedRankTest(logs);
234
+ const rnd = seeded(11);
235
+ const boots = [];
236
+ for (let i = 0; i < 4000; i++) { const draw = logs.map(() => logs[Math.floor(rnd() * logs.length)]); boots.push(Math.exp(median(draw)) - 1); }
237
+ const ci = [round(quantile(boots, 0.025), 4), round(quantile(boots, 0.975), 4)];
238
+ const res = { ...base, change: round(change, 4), p: t.less, pWorse: t.greater, ci, n: [pairs.length, pairs.length], wins: t.wins };
239
+ const head = `${say(sa.median)} → ${say(sb.median)} (${pct(change)} in the median pair; after was lower in ${t.wins} of ${pairs.length} pairs run side by side; 95% interval ${pct(ci[0])} to ${pct(ci[1])}; p = ${t.less} better, ${t.greater} worse)`;
240
+ if (t.less < alpha && ci[1] < 0 && change <= -min) return { ...res, verdict: 'better', why: head };
241
+ // Few pairs cannot reach p < 0.01 (6 pairs: 1/64 at best), so the guard's bar is then "worse in every pair".
242
+ const wa = Math.max(worseAlpha, 1.5 / 2 ** pairs.length);
243
+ if (t.greater < wa && ci[0] > 0 && change >= worseMin) return { ...res, verdict: 'worse', why: head };
244
+ const reasons = [];
245
+ if (change > -min) reasons.push(`smaller than the ${Math.round(min * 100)}% that counts`);
246
+ if (t.less >= alpha) reasons.push(`p ${t.less} is not under ${alpha}`);
247
+ if (ci[1] >= 0) reasons.push('the interval reaches zero');
248
+ return { ...res, verdict: 'same', why: `${head}: within the noise (${reasons.join('; ') || 'not clearly either way'})` };
249
+ }