peeksafe 0.0.0-stage → 0.2.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 (66) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +78 -0
  4. package/README.md +490 -2
  5. package/RESEARCH-NOTES.md +274 -0
  6. package/dist/baseline.d.ts +45 -0
  7. package/dist/baseline.d.ts.map +1 -0
  8. package/dist/baseline.js +49 -0
  9. package/dist/baseline.js.map +1 -0
  10. package/dist/check.d.ts +64 -0
  11. package/dist/check.d.ts.map +1 -0
  12. package/dist/check.js +172 -0
  13. package/dist/check.js.map +1 -0
  14. package/dist/cluster.d.ts +328 -0
  15. package/dist/cluster.d.ts.map +1 -0
  16. package/dist/cluster.js +606 -0
  17. package/dist/cluster.js.map +1 -0
  18. package/dist/errors.d.ts +108 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +192 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/evidence.d.ts +15 -0
  23. package/dist/evidence.d.ts.map +1 -0
  24. package/dist/evidence.js +30 -0
  25. package/dist/evidence.js.map +1 -0
  26. package/dist/frontier.d.ts +271 -0
  27. package/dist/frontier.d.ts.map +1 -0
  28. package/dist/frontier.js +505 -0
  29. package/dist/frontier.js.map +1 -0
  30. package/dist/gate.d.ts +175 -0
  31. package/dist/gate.d.ts.map +1 -0
  32. package/dist/gate.js +200 -0
  33. package/dist/gate.js.map +1 -0
  34. package/dist/index.d.ts +60 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +79 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/plan.d.ts +280 -0
  39. package/dist/plan.d.ts.map +1 -0
  40. package/dist/plan.js +626 -0
  41. package/dist/plan.js.map +1 -0
  42. package/dist/rand.d.ts +17 -0
  43. package/dist/rand.d.ts.map +1 -0
  44. package/dist/rand.js +104 -0
  45. package/dist/rand.js.map +1 -0
  46. package/dist/stats.d.ts +401 -0
  47. package/dist/stats.d.ts.map +1 -0
  48. package/dist/stats.js +1304 -0
  49. package/dist/stats.js.map +1 -0
  50. package/dist/stop.d.ts +164 -0
  51. package/dist/stop.d.ts.map +1 -0
  52. package/dist/stop.js +270 -0
  53. package/dist/stop.js.map +1 -0
  54. package/package.json +62 -3
  55. package/src/baseline.ts +72 -0
  56. package/src/check.ts +238 -0
  57. package/src/cluster.ts +842 -0
  58. package/src/errors.ts +275 -0
  59. package/src/evidence.ts +62 -0
  60. package/src/frontier.ts +837 -0
  61. package/src/gate.ts +363 -0
  62. package/src/index.ts +206 -0
  63. package/src/plan.ts +968 -0
  64. package/src/rand.ts +137 -0
  65. package/src/stats.ts +1663 -0
  66. package/src/stop.ts +464 -0
package/src/cluster.ts ADDED
@@ -0,0 +1,842 @@
1
+ /**
2
+ * cluster.ts: the suite-level effect, with the cases treated as what they are.
3
+ *
4
+ * ## The bug this module exists to fix
5
+ *
6
+ * The obvious summary of a suite effect is an ordinary normal interval over the
7
+ * per-case differences:
8
+ *
9
+ * mean ± 1.96 · sd / √n with n = 200 cases
10
+ *
11
+ * That formula is only correct if the 200 numbers are 200 independent draws.
12
+ * They usually are not. A suite of **40 case files, each expanded into 5
13
+ * variants**, is typical: `extract.pricing#saas` and `extract.pricing#api`
14
+ * share a prompt template, a grader, an expectation shape and a failure mode.
15
+ * When one moves, its four siblings move with it. The effective number of
16
+ * independent observations is closer to 40 than to 200, the true standard error
17
+ * is larger than √(s²/200), and the reported interval is **too narrow**.
18
+ *
19
+ * A too-narrow interval on the headline number is the exact failure this whole
20
+ * package exists to prevent, so it gets a proper estimator rather than a fudge
21
+ * factor.
22
+ *
23
+ * ## What is estimated here, and what is not
24
+ *
25
+ * Clustering touches **only the suite-level (and family-level) summary**.
26
+ * Specifically:
27
+ *
28
+ * affected the across-case mean difference and its interval
29
+ * (`clusteredEffect`), plus any family-level roll-up.
30
+ *
31
+ * NOT affected every per-case quantity. Each case's e-value is its own
32
+ * self-contained test on that case's own runs; nothing about
33
+ * case *A* enters case *B*'s likelihood ratio. And the
34
+ * multiplicity correction is **e-BH**, which controls FDR under
35
+ * *arbitrary* dependence between the tested hypotheses, that
36
+ * is the whole reason it was chosen over BH. So `gate`'s
37
+ * verdict, its regressed cases and its thresholds are all
38
+ * bit-for-bit identical whether or not the cases cluster.
39
+ *
40
+ * In other words: clustering makes the *description* honest. It does not, and
41
+ * must not, move the *decision*.
42
+ *
43
+ * ## Two estimators, because they fail in different directions
44
+ *
45
+ * **CR2 (the headline).** A cluster-robust sandwich with the Bell-McCaffrey
46
+ * bias reduction. For the intercept-only model that a mean is, the hat matrix
47
+ * is H = J/n, so each cluster's leverage block is (I − J_{n_g}/n) and the CR2
48
+ * adjustment `A_g = (I − H_gg)^{-1/2}` collapses to a scalar on the cluster's
49
+ * residual sum:
50
+ *
51
+ * Var̂(ȳ) = (1/n²) · Σ_g (Σ_{i∈g} ê_i)² / (1 − n_g/n)
52
+ *
53
+ * Assumption-light: it needs the *families* to be independent of each other and
54
+ * nothing at all about the correlation structure *inside* a family. It does not
55
+ * assume the family effects are normal, or exchangeable, or even
56
+ * identically distributed. Paired with Bell-McCaffrey/Satterthwaite degrees of
57
+ * freedom (which, for equal-sized families, reduce exactly to G − 1; see the
58
+ * derivation in the comment above `dfRaw` below) it is the estimator you want
59
+ * when there are 40 clusters rather than 4,000.
60
+ *
61
+ * **Random effects (the cross-check).** The one-way model
62
+ * y_gi = μ + u_g + e_gi with the ANOVA (Swamy-Arora) estimators for τ² and σ².
63
+ * More efficient *if* its assumptions hold, and it yields the number that
64
+ * actually explains the width change: the intra-class correlation
65
+ * ρ = τ²/(τ² + σ²). If the two estimators disagree materially, that is itself
66
+ * information, and `compareEstimators` says so rather than picking a winner.
67
+ *
68
+ * ## The design effect
69
+ *
70
+ * `designEffect` = (clustered SE / naive iid SE)². It is the factor by which
71
+ * the naive analysis overstated its own information, "these 200 cases carry
72
+ * the information of 200/DEFF independent ones". For roughly equal families of
73
+ * size k it tracks the textbook 1 + (k − 1)ρ, which is the sanity check the
74
+ * tests assert.
75
+ */
76
+
77
+ import { PeeksafeError } from "./errors.js";
78
+ import { type Interval, normalQuantile, tQuantile } from "./stats.js";
79
+
80
+ /* ────────────────────────── the clustering key ───────────────────────── */
81
+
82
+ /**
83
+ * The cluster a case belongs to: its **case file**.
84
+ *
85
+ * Variant ids are expected as `<fileId>#<variant>`, so everything before
86
+ * the first `#` is the file, and the file is the unit an author actually wrote.
87
+ * A case with no `#` is its own family of one, which is correct, not a
88
+ * degenerate case: an unexpanded case file shares its template with nobody.
89
+ */
90
+ export function caseFamily(caseId: string): string {
91
+ const i = caseId.indexOf("#");
92
+ return i < 0 ? caseId : caseId.slice(0, i);
93
+ }
94
+
95
+ /**
96
+ * The cluster key for a case: the one it **declares**, or its case file.
97
+ *
98
+ * `caseFamily` sees exactly one reason two cases correlate, that they were
99
+ * minted from the same file, and is blind to every other: a shared grader, a
100
+ * shared fixture directory, one flaky tool that six unrelated cases happen to
101
+ * call. Those cases are correlated too, and the interval will be too narrow
102
+ * again for a reason the id cannot express. So a case may declare its own key.
103
+ *
104
+ * A wrong key is worse than no key, which is why `clusterKeyDiagnostic` ships
105
+ * alongside: declaring two genuinely independent cases into one family throws
106
+ * away real information and widens the interval for nothing, and the only way
107
+ * to know is to look at the ICC the declared grouping actually exhibits.
108
+ */
109
+ export const clusterKey = (c: { id: string; cluster?: string }): string => {
110
+ // Trimmed, so `cluster: " "` is not a distinct family that silently merges
111
+ // every case that has one. An all-whitespace key is no key.
112
+ const declared = c.cluster?.trim();
113
+ return declared ? declared : caseFamily(c.id);
114
+ };
115
+
116
+ export interface ClusterObservation {
117
+ /** the family this observation belongs to */
118
+ cluster: string;
119
+ value: number;
120
+ }
121
+
122
+ // Called before the early returns, which never reach a quantile, so every suite refuses the same level.
123
+ function requireLevel(level: number, where: string): void {
124
+ if (!(level > 0 && level < 1)) {
125
+ throw new PeeksafeError(
126
+ "PEEKSAFE_E_STAT_DOMAIN",
127
+ `${where}: level must be in (0,1), got ${level}`,
128
+ {
129
+ detail: { level },
130
+ hint: "a two-sided confidence level such as 0.95, not a percentage and not an error budget",
131
+ },
132
+ );
133
+ }
134
+ }
135
+
136
+ /** Group observations by cluster, preserving first-seen order. */
137
+ export function groupByCluster(obs: ClusterObservation[]): Map<string, number[]> {
138
+ const m = new Map<string, number[]>();
139
+ for (const o of obs) {
140
+ if (!Number.isFinite(o.value)) {
141
+ throw new PeeksafeError(
142
+ "PEEKSAFE_E_STAT_DOMAIN",
143
+ `clustered mean: value for cluster "${o.cluster}" is not finite`,
144
+ {
145
+ detail: { cluster: o.cluster, value: o.value },
146
+ },
147
+ );
148
+ }
149
+ const xs = m.get(o.cluster);
150
+ if (xs) xs.push(o.value);
151
+ else m.set(o.cluster, [o.value]);
152
+ }
153
+ return m;
154
+ }
155
+
156
+ /* ──────────────────────────── the estimators ─────────────────────────── */
157
+
158
+ export interface ClusteredMean extends Interval {
159
+ /** the mean itself, same number the naive estimator reports */
160
+ point: number;
161
+ low: number;
162
+ high: number;
163
+ /** number of *cases* behind the estimate */
164
+ observations: number;
165
+ /** number of *independent* units behind the estimate, the one that matters */
166
+ clusters: number;
167
+ largestCluster: number;
168
+ meanClusterSize: number;
169
+ /** CR2 cluster-robust standard error of the mean */
170
+ se: number;
171
+ /** the iid standard error the naive interval used, s/√n */
172
+ naiveSe: number;
173
+ /** (se / naiveSe)², the factor by which the naive analysis overstated its information */
174
+ designEffect: number;
175
+ /** how much wider the honest interval is, half-width to half-width */
176
+ widthRatio: number;
177
+ /** Bell-McCaffrey / Satterthwaite degrees of freedom */
178
+ df: number;
179
+ /** the t multiplier actually used (vs `normalQuantile` for the naive one) */
180
+ tCritical: number;
181
+ /** how many independent cases this suite is *worth*, n / designEffect */
182
+ effectiveSampleSize: number;
183
+ method: "CR2";
184
+ /** set when the estimate could not be formed; `low`/`high` are then vacuous */
185
+ degenerate: string | null;
186
+ }
187
+
188
+ /** The naive iid interval, kept so the two can be printed side by side. */
189
+ export function iidMean(values: number[], level = 0.95): Interval {
190
+ requireLevel(level, "iidMean");
191
+ // groupByCluster refused these for the other estimators, so the same data was an error there and a NaN interval here.
192
+ for (let i = 0; i < values.length; i++) {
193
+ if (!Number.isFinite(values[i])) {
194
+ throw new PeeksafeError(
195
+ "PEEKSAFE_E_STAT_DOMAIN",
196
+ `iidMean: value at index ${i} is not finite`,
197
+ {
198
+ detail: { index: i, value: values[i] },
199
+ },
200
+ );
201
+ }
202
+ }
203
+ const n = values.length;
204
+ if (n === 0) return { low: -1, high: 1, point: 0, observations: 0 };
205
+ const mean = values.reduce((a, b) => a + b, 0) / n;
206
+ const sd = n > 1 ? Math.sqrt(values.reduce((a, x) => a + (x - mean) ** 2, 0) / (n - 1)) : 0;
207
+ const z = normalQuantile(1 - (1 - level) / 2);
208
+ const half = (z * sd) / Math.sqrt(n);
209
+ return { low: mean - half, high: mean + half, point: mean, observations: n };
210
+ }
211
+
212
+ /**
213
+ * CR2 cluster-robust interval for a mean, with Bell-McCaffrey degrees of
214
+ * freedom. See the module header for the derivation; the short version is that
215
+ * for an intercept-only design the whole apparatus collapses to
216
+ *
217
+ * Var̂ = (1/n²) Σ_g (Σ_{i∈g} ê_i)² / (1 − n_g/n)
218
+ *
219
+ * and the Satterthwaite df to a closed form in the cluster sizes alone.
220
+ */
221
+ export function clusterRobustMean(obs: ClusterObservation[], level = 0.95): ClusteredMean {
222
+ requireLevel(level, "clusterRobustMean");
223
+ const groups = groupByCluster(obs);
224
+ const n = obs.length;
225
+ const G = groups.size;
226
+ const sizes = [...groups.values()].map((xs) => xs.length);
227
+ const naive = iidMean(
228
+ obs.map((o) => o.value),
229
+ level,
230
+ );
231
+ const naiveSe =
232
+ n > 1 ? Math.sqrt(obs.reduce((a, o) => a + (o.value - naive.point) ** 2, 0) / (n - 1) / n) : 0;
233
+ const base = {
234
+ point: naive.point,
235
+ observations: n,
236
+ clusters: G,
237
+ largestCluster: sizes.length ? Math.max(...sizes) : 0,
238
+ meanClusterSize: G > 0 ? n / G : 0,
239
+ naiveSe,
240
+ method: "CR2" as const,
241
+ };
242
+
243
+ // One cluster is one observation. There is nothing to average over, so there
244
+ // is no standard error, and saying "±0.4 points" here would be exactly the
245
+ // fabricated-precision failure this module was written to remove.
246
+ if (n === 0 || G < 2) {
247
+ return {
248
+ ...base,
249
+ low: -1,
250
+ high: 1,
251
+ se: Infinity,
252
+ designEffect: Infinity,
253
+ widthRatio: Infinity,
254
+ df: 0,
255
+ tCritical: Infinity,
256
+ effectiveSampleSize: G,
257
+ degenerate:
258
+ n === 0
259
+ ? "no cases contributed an estimate"
260
+ : `all ${n} cases belong to one family ("${[...groups.keys()][0]}"), a single cluster carries no ` +
261
+ "information about between-family variation, so no interval is estimable",
262
+ };
263
+ }
264
+
265
+ const mean = base.point;
266
+ let meat = 0;
267
+ for (const xs of groups.values()) {
268
+ const sumRes = xs.reduce((a, x) => a + (x - mean), 0);
269
+ const leverage = 1 - xs.length / n; // > 0 because G ≥ 2
270
+ meat += (sumRes * sumRes) / leverage;
271
+ }
272
+ const variance = meat / (n * n);
273
+ const se = Math.sqrt(variance);
274
+
275
+ // Bell-McCaffrey / Satterthwaite df. With g_g = M b_g the closed forms are
276
+ // tr(G) = Σ n_g/n² = 1/n
277
+ // tr(G²) = Σ_g (n_g/n²)² + Σ_{g≠h} (n_g n_h)² / (n⁶ (1−n_g/n)(1−n_h/n))
278
+ // and df = tr(G)² / tr(G²). For equal cluster sizes this is exactly G − 1.
279
+ const n2 = n * n;
280
+ let trG2 = 0;
281
+ for (let i = 0; i < G; i++) {
282
+ const ni = sizes[i]!;
283
+ trG2 += (ni / n2) ** 2;
284
+ for (let j = 0; j < G; j++) {
285
+ if (i === j) continue;
286
+ const nj = sizes[j]!;
287
+ const denom = (1 - ni / n) * (1 - nj / n);
288
+ trG2 += (ni * nj) ** 2 / (n2 * n2 * n2 * denom);
289
+ }
290
+ }
291
+ const trG = 1 / n;
292
+ const dfRaw = trG2 > 0 ? (trG * trG) / trG2 : G - 1;
293
+ const df = Math.min(n - 1, Math.max(1, dfRaw));
294
+
295
+ const t = tQuantile(1 - (1 - level) / 2, df);
296
+ const half = t * se;
297
+ const z = normalQuantile(1 - (1 - level) / 2);
298
+ const naiveHalf = z * naiveSe;
299
+
300
+ return {
301
+ ...base,
302
+ low: mean - half,
303
+ high: mean + half,
304
+ se,
305
+ designEffect: naiveSe > 0 ? (se / naiveSe) ** 2 : se > 0 ? Infinity : 1,
306
+ widthRatio: naiveHalf > 0 ? half / naiveHalf : half > 0 ? Infinity : 1,
307
+ df,
308
+ tCritical: t,
309
+ // n / DEFF, **capped at n**. When the families happen to disagree less than
310
+ // the cases within them, the CR2 standard error lands below the iid one and
311
+ // DEFF comes out under 1, a finite-sample fact about this sample, not
312
+ // information the clustering created. Uncapped, this read "worth
313
+ // 222 independent cases / 200", which claims a suite of 200 carries more
314
+ // than 200 cases' worth of evidence. It does not. The design effect itself
315
+ // is reported unclamped, because *that* number is the estimate.
316
+ effectiveSampleSize: naiveSe > 0 ? Math.min(n, n / Math.max(1e-12, (se / naiveSe) ** 2)) : n,
317
+ degenerate: null,
318
+ };
319
+ }
320
+
321
+ /* ───────────────────── hierarchical / random effects ─────────────────── */
322
+
323
+ export interface RandomEffectsMean extends Interval {
324
+ clusters: number;
325
+ observations: number;
326
+ /** between-family variance τ̂² (ANOVA / Swamy-Arora, floored at 0) */
327
+ tauSquared: number;
328
+ /** within-family variance σ̂² */
329
+ sigmaSquared: number;
330
+ /** intra-class correlation τ̂²/(τ̂²+σ̂²), the number that explains the widening */
331
+ icc: number;
332
+ se: number;
333
+ df: number;
334
+ method: "random-effects";
335
+ degenerate: string | null;
336
+ }
337
+
338
+ /**
339
+ * One-way random-effects (hierarchical) estimate of the same mean.
340
+ *
341
+ * y_gi = μ + u_g + e_gi, u_g ~ (0, τ²), e_gi ~ (0, σ²)
342
+ *
343
+ * τ² and σ² come from the ANOVA decomposition (the Swamy-Arora estimator,
344
+ * unbiased and closed-form, and it does not need the normality that REML would
345
+ * lean on). μ̂ is the GLS weighted mean of the family means with
346
+ * w_g = 1/(τ² + σ²/n_g), so its variance is 1/Σw_g.
347
+ *
348
+ * Two properties worth knowing before trusting it:
349
+ * - at τ̂² = 0 it degenerates **exactly** to the naive iid interval, which is
350
+ * the correct behaviour: no between-family variance means the cases really
351
+ * were exchangeable;
352
+ * - it weights families by information rather than by size, so unlike the
353
+ * unweighted mean it is not the same point estimate as CR2 when families
354
+ * differ in size. That difference is a feature and it is reported.
355
+ */
356
+ export function randomEffectsMean(obs: ClusterObservation[], level = 0.95): RandomEffectsMean {
357
+ requireLevel(level, "randomEffectsMean");
358
+ const groups = groupByCluster(obs);
359
+ const n = obs.length;
360
+ const G = groups.size;
361
+ const grand = n > 0 ? obs.reduce((a, o) => a + o.value, 0) / n : 0;
362
+ const shell = {
363
+ observations: n,
364
+ clusters: G,
365
+ method: "random-effects" as const,
366
+ };
367
+ if (n === 0 || G < 2) {
368
+ return {
369
+ ...shell,
370
+ point: grand,
371
+ low: -1,
372
+ high: 1,
373
+ tauSquared: 0,
374
+ sigmaSquared: 0,
375
+ icc: 0,
376
+ se: Infinity,
377
+ df: 0,
378
+ degenerate:
379
+ n === 0 ? "no cases contributed an estimate" : "a single family cannot separate τ² from σ²",
380
+ };
381
+ }
382
+
383
+ const entries = [...groups.entries()].map(([k, xs]) => ({
384
+ key: k,
385
+ size: xs.length,
386
+ mean: xs.reduce((a, x) => a + x, 0) / xs.length,
387
+ values: xs,
388
+ }));
389
+
390
+ let ssw = 0;
391
+ let ssb = 0;
392
+ for (const g of entries) {
393
+ for (const x of g.values) ssw += (x - g.mean) ** 2;
394
+ ssb += g.size * (g.mean - grand) ** 2;
395
+ }
396
+ const dfW = n - G;
397
+ const dfB = G - 1;
398
+
399
+ // Every family has exactly one member: there is no within-family replication,
400
+ // so σ² is not identified, and there is also no clustering left to correct
401
+ // for. The iid interval is then the right answer, not an approximation.
402
+ if (dfW === 0) {
403
+ const iid = iidMean(
404
+ obs.map((o) => o.value),
405
+ level,
406
+ );
407
+ return {
408
+ ...shell,
409
+ point: iid.point,
410
+ low: iid.low,
411
+ high: iid.high,
412
+ tauSquared: dfB > 0 ? ssb / dfB : 0,
413
+ sigmaSquared: 0,
414
+ icc: 1,
415
+ se: dfB > 0 ? Math.sqrt(ssb / dfB / n) : 0,
416
+ df: dfB,
417
+ degenerate:
418
+ "every family has exactly one case, no within-family replication, so σ² is not identified " +
419
+ "and the observations are already independent",
420
+ };
421
+ }
422
+
423
+ const msw = ssw / dfW;
424
+ const msb = ssb / dfB;
425
+ // k₀: the "average" cluster size the ANOVA estimator divides by. Equals the
426
+ // common size when families are balanced.
427
+ const sumSq = entries.reduce((a, g) => a + g.size * g.size, 0);
428
+ const k0 = (n - sumSq / n) / dfB;
429
+ const sigmaSquared = msw;
430
+ const tauSquared = k0 > 0 ? Math.max(0, (msb - msw) / k0) : 0;
431
+
432
+ // Neither variance component is positive, so every case took the same value.
433
+ // The GLS weight 1/(τ² + σ²/n_g) is then 1/0 for every family, the total
434
+ // weight is Infinity, and the weighted mean came out Infinity/Infinity, i.e.
435
+ // NaN, with `degenerate` left null, which says the estimate was formed. A
436
+ // pull request that moved nothing produces exactly this: every per-case
437
+ // difference is zero. CR2 and the iid interval both answer the grand mean
438
+ // with a zero-width interval here, and so does this.
439
+ if (!(tauSquared + sigmaSquared > 0)) {
440
+ return {
441
+ ...shell,
442
+ point: grand,
443
+ low: grand,
444
+ high: grand,
445
+ tauSquared: 0,
446
+ sigmaSquared: 0,
447
+ icc: 0,
448
+ se: 0,
449
+ df: dfB,
450
+ degenerate: null,
451
+ };
452
+ }
453
+
454
+ let sumW = 0;
455
+ let sumWY = 0;
456
+ for (const g of entries) {
457
+ const w = 1 / (tauSquared + sigmaSquared / g.size);
458
+ sumW += w;
459
+ sumWY += w * g.mean;
460
+ }
461
+ const point = sumW > 0 ? sumWY / sumW : grand;
462
+ const se = sumW > 0 ? Math.sqrt(1 / sumW) : Infinity;
463
+ const df = dfB;
464
+ const t = tQuantile(1 - (1 - level) / 2, df);
465
+
466
+ return {
467
+ ...shell,
468
+ point,
469
+ low: point - t * se,
470
+ high: point + t * se,
471
+ se,
472
+ tauSquared,
473
+ sigmaSquared,
474
+ icc: tauSquared + sigmaSquared > 0 ? tauSquared / (tauSquared + sigmaSquared) : 0,
475
+ df,
476
+ degenerate: null,
477
+ };
478
+ }
479
+
480
+ /* ───────────────────────── the combined report ───────────────────────── */
481
+
482
+ export interface FamilyEffect {
483
+ family: string;
484
+ cases: number;
485
+ /** unweighted mean of the family's per-case effects */
486
+ point: number;
487
+ /**
488
+ * Spread of the family's own cases. Deliberately **not** an interval: the
489
+ * variants inside a family are the correlated things, so an interval over
490
+ * five of them would repeat the very mistake this module fixes, one level
491
+ * down. A family of five variants is one observation, not five.
492
+ */
493
+ sd: number;
494
+ min: number;
495
+ max: number;
496
+ }
497
+
498
+ export interface ClusteredEffect {
499
+ /** the honest headline: CR2 with small-cluster degrees of freedom */
500
+ cr2: ClusteredMean;
501
+ /** the hierarchical cross-check, and where the ICC comes from */
502
+ randomEffects: RandomEffectsMean;
503
+ /** the naive iid interval, which assumes every case is independent */
504
+ naive: Interval;
505
+ /** (clustered SE / naive SE)² */
506
+ designEffect: number;
507
+ /** how much wider the honest interval is */
508
+ widthRatio: number;
509
+ icc: number;
510
+ clusters: number;
511
+ observations: number;
512
+ /** per case-file roll-up, point estimates only, and the docs say why */
513
+ families: FamilyEffect[];
514
+ /**
515
+ * True when the naive interval excluded zero and the honest one does not.
516
+ * This is the finding: a claim that "the suite moved" which does not survive
517
+ * being told that the cases are not independent.
518
+ */
519
+ signFlippedByClustering: boolean;
520
+ /** one line an operator can read without knowing what CR2 is */
521
+ headline: string;
522
+ }
523
+
524
+ const fmtPts = (x: number) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}`;
525
+
526
+ /**
527
+ * The whole suite-level story in one object: the honest interval, the naive one
528
+ * it replaces, the factor between them, and whether swapping them changes what
529
+ * a reader would conclude.
530
+ */
531
+ export function clusteredEffect(obs: ClusterObservation[], level = 0.95): ClusteredEffect {
532
+ const cr2 = clusterRobustMean(obs, level);
533
+ const re = randomEffectsMean(obs, level);
534
+ const naive = iidMean(
535
+ obs.map((o) => o.value),
536
+ level,
537
+ );
538
+
539
+ const groups = groupByCluster(obs);
540
+ const families: FamilyEffect[] = [...groups.entries()]
541
+ .map(([family, xs]) => {
542
+ const m = xs.reduce((a, x) => a + x, 0) / xs.length;
543
+ const sd =
544
+ xs.length > 1 ? Math.sqrt(xs.reduce((a, x) => a + (x - m) ** 2, 0) / (xs.length - 1)) : 0;
545
+ return { family, cases: xs.length, point: m, sd, min: Math.min(...xs), max: Math.max(...xs) };
546
+ })
547
+ .sort((a, b) => a.point - b.point || (a.family < b.family ? -1 : 1));
548
+
549
+ const excludesZero = (i: Interval) => i.low > 0 || i.high < 0;
550
+ const signFlippedByClustering = excludesZero(naive) && !excludesZero(cr2);
551
+
552
+ const headline = cr2.degenerate
553
+ ? `suite effect ${fmtPts(cr2.point)}pts, no interval: ${cr2.degenerate}`
554
+ : `suite effect ${fmtPts(cr2.point)}pts [${fmtPts(cr2.low)}, ${fmtPts(cr2.high)}] ` +
555
+ `over ${cr2.clusters} case families (${cr2.observations} cases); ` +
556
+ `clustering widens the interval ${cr2.widthRatio.toFixed(2)}× (design effect ${cr2.designEffect.toFixed(2)}, ` +
557
+ `ICC ${re.icc.toFixed(2)}), so the suite is worth ~${Math.round(cr2.effectiveSampleSize)} independent cases, not ${cr2.observations}` +
558
+ (signFlippedByClustering
559
+ ? ". The naive interval excluded zero and this one does not, the apparent suite-level move does not survive the correction."
560
+ : "");
561
+
562
+ return {
563
+ cr2,
564
+ randomEffects: re,
565
+ naive,
566
+ designEffect: cr2.designEffect,
567
+ widthRatio: cr2.widthRatio,
568
+ icc: re.icc,
569
+ clusters: cr2.clusters,
570
+ observations: cr2.observations,
571
+ families,
572
+ signFlippedByClustering,
573
+ headline,
574
+ };
575
+ }
576
+
577
+ /**
578
+ * Do the two estimators tell the same story? They rest on different
579
+ * assumptions, so agreement is evidence and disagreement is a warning worth
580
+ * printing rather than resolving by fiat.
581
+ */
582
+ export function compareEstimators(e: ClusteredEffect): {
583
+ agree: boolean;
584
+ /** ratio of the two standard errors, robust ÷ model-based */
585
+ seRatio: number;
586
+ note: string;
587
+ } {
588
+ if (e.cr2.degenerate || e.randomEffects.degenerate) {
589
+ return { agree: true, seRatio: 1, note: e.cr2.degenerate ?? e.randomEffects.degenerate ?? "" };
590
+ }
591
+ // Two estimators that both report a standard error of zero agree; it is only
592
+ // a zero model-based SE under a positive robust one that is an infinite
593
+ // ratio. Reading every zero as Infinity made the note say "the family effects
594
+ // are not behaving like independent draws from one distribution, so trust CR2
595
+ // and not the hierarchical fit" about a suite with no variance at all, where
596
+ // the two estimators had produced the same number. Same shape as
597
+ // `designEffect` in `clusterRobustMean`, for the same reason.
598
+ const seRatio =
599
+ e.randomEffects.se > 0 ? e.cr2.se / e.randomEffects.se : e.cr2.se > 0 ? Infinity : 1;
600
+ const agree = seRatio > 0.7 && seRatio < 1.45;
601
+ return {
602
+ agree,
603
+ seRatio,
604
+ note: agree
605
+ ? `CR2 and the random-effects model agree to within ${(Math.abs(seRatio - 1) * 100).toFixed(0)}% on the standard error`
606
+ : seRatio > 1
607
+ ? `the robust SE is ${seRatio.toFixed(2)}× the model-based one, the family effects are not behaving like ` +
608
+ "independent draws from one distribution, so trust CR2 and not the hierarchical fit"
609
+ : `the robust SE is only ${seRatio.toFixed(2)}× the model-based one, with this few families CR2 can be ` +
610
+ "optimistic; report the wider of the two",
611
+ };
612
+ }
613
+
614
+ /* ─────────────────── is the declared cluster key earning it? ──────────── */
615
+
616
+ export interface GroupingSummary {
617
+ key: string;
618
+ /** why this grouping has no estimable interval, when it has none */
619
+ degenerate: string | null;
620
+ clusters: number;
621
+ meanClusterSize: number;
622
+ /**
623
+ * The ANOVA intra-class correlation, **only when `iccEstimable`**. With one
624
+ * case per family σ̂² is 0 by construction and this comes out at exactly 1,
625
+ * which is an artifact of the decomposition and not a measurement: a grouping
626
+ * of singletons says nothing at all about within-family correlation. Read the
627
+ * flag before the number.
628
+ */
629
+ icc: number;
630
+ designEffect: number;
631
+ /** how much wider this grouping's interval is than the iid one. Not 1/DEFF:
632
+ * a coarse grouping also costs degrees of freedom, and a few large clusters
633
+ * can have a *smaller* design effect and a *much wider* interval. */
634
+ widthRatio: number;
635
+ se: number;
636
+ /** cases per independent unit, `observations / designEffect` */
637
+ effectiveSampleSize: number;
638
+ /** false when the random-effects fit cannot separate τ² from σ², so `icc` is not a measurement */
639
+ iccEstimable: boolean;
640
+ }
641
+
642
+ export interface ClusterKeyDiagnostic {
643
+ declared: GroupingSummary;
644
+ /** the same observations grouped by case file, which is what ships by default */
645
+ fallback: GroupingSummary;
646
+ /**
647
+ * The declared grouping is *coarser* than the fallback and its groups show no
648
+ * material correlation, it merged cases that do not move together, and got
649
+ * nothing for it.
650
+ */
651
+ mergesUncorrelatedCases: boolean;
652
+ /**
653
+ * …and the resulting interval is **narrower** than the default's, on too few
654
+ * clusters for CR2 to be trusted. This is the dangerous direction, and it was
655
+ * not the one the first version of this diagnostic looked for: a coarse key
656
+ * with near-zero ICC can have a design effect *below* 1 and produce a tighter
657
+ * interval than the honest one, which is the exact failure `cluster.ts` was
658
+ * written to remove, reintroduced by a user-supplied key.
659
+ */
660
+ narrowsOnTooFewClusters: boolean;
661
+ /**
662
+ * The mirror image, and the one the first two flags cannot see because both
663
+ * require the declared key to be *coarser*: a key that is **finer** than the
664
+ * case-file default, splitting apart families that do move together, and
665
+ * buying a narrower interval with the split. It is the same too-narrow
666
+ * headline `narrowsOnTooFewClusters` catches, arrived at from the other side,
667
+ * and it is not a small effect: dissolving eight ten-variant files into
668
+ * eighty singletons takes the interval from 3.90× the iid one to 1.02×, a
669
+ * claim that the suite carries eighty independent cases' worth of evidence
670
+ * when it carries about eight. Both figures are asserted in
671
+ * `cluster.test.ts`.
672
+ */
673
+ narrowsBySplitting: boolean;
674
+ /** True when the declared grouping found correlation the case ids could not see. */
675
+ findsHiddenCorrelation: boolean;
676
+ verdict: string;
677
+ }
678
+
679
+ /**
680
+ * Below this many clusters, a cluster-robust standard error is optimistic even
681
+ * with small-cluster degrees of freedom. Standard practice; `compareEstimators`
682
+ * says the same thing from the other direction.
683
+ */
684
+ export const MIN_TRUSTWORTHY_CLUSTERS = 20;
685
+
686
+ const summarise = (key: string, obs: ClusterObservation[]): GroupingSummary => {
687
+ const cr2 = clusterRobustMean(obs);
688
+ const re = randomEffectsMean(obs);
689
+ return {
690
+ key,
691
+ // Only CR2 decides whether this grouping has an interval, which is what
692
+ // `degenerate` documents. The random-effects fit degenerating is a
693
+ // different fact about a different estimator: with one case per family σ²
694
+ // is not identified, so there is no ICC to report, but the cluster-robust
695
+ // interval is estimable and is in fact the iid one. Folding the two
696
+ // together made `clusterKeyDiagnostic` answer "no suite-level interval is
697
+ // estimable ... it is no claim at all" about a grouping whose standard
698
+ // error it had just computed, in a sentence reading "collapses 8 case-file
699
+ // groups into 80".
700
+ degenerate: cr2.degenerate,
701
+ clusters: cr2.clusters,
702
+ meanClusterSize: cr2.meanClusterSize,
703
+ icc: re.icc,
704
+ designEffect: cr2.designEffect,
705
+ widthRatio: cr2.widthRatio,
706
+ se: cr2.se,
707
+ effectiveSampleSize: cr2.effectiveSampleSize,
708
+ iccEstimable: re.degenerate === null,
709
+ };
710
+ };
711
+
712
+ /**
713
+ * Does a user-declared cluster key carry anything?
714
+ *
715
+ * The honest answer is a comparison, not a number: the same observations are
716
+ * grouped both ways and the intra-class correlation of each grouping is
717
+ * reported. A declared key that merges cases which do not in fact move together
718
+ * shows an ICC near zero while having *fewer, larger* clusters than the
719
+ * fallback, it has thrown away real information and widened the interval to
720
+ * pay for it. A declared key that finds correlation the case ids could not see
721
+ * shows a higher ICC and a larger design effect, and is doing its job. A
722
+ * declared key that is *finer* than the fallback, splitting apart files whose
723
+ * variants do move together, narrows the interval instead, and that is the
724
+ * dangerous direction rather than the merely wasteful one.
725
+ *
726
+ * Neither outcome is corrected automatically. A cluster key is a claim about
727
+ * the world and only the person who made it can say whether it is true; this
728
+ * says what the data thinks of it.
729
+ */
730
+ export function clusterKeyDiagnostic(
731
+ declared: ClusterObservation[],
732
+ fallback: ClusterObservation[],
733
+ ): ClusterKeyDiagnostic {
734
+ if (declared.length !== fallback.length) {
735
+ throw new PeeksafeError(
736
+ "PEEKSAFE_E_STAT_DOMAIN",
737
+ `clusterKeyDiagnostic: the two groupings must cover the same observations (${declared.length} vs ${fallback.length})`,
738
+ {
739
+ detail: { declared: declared.length, fallback: fallback.length },
740
+ hint: "group the same value array twice; do not filter one of them",
741
+ },
742
+ );
743
+ }
744
+ const d = summarise("declared", declared);
745
+ const f = summarise("case-id", fallback);
746
+
747
+ // A grouping with fewer than two clusters has no estimable between-family
748
+ // variance, so its design effect is Infinity and its ICC is meaningless.
749
+ // Comparing those against the fallback produces a verdict full of `Infinity×`
750
+ // that reads like a very large finding and is really an absence of one.
751
+ if (d.degenerate !== null) {
752
+ return {
753
+ declared: d,
754
+ fallback: f,
755
+ mergesUncorrelatedCases: true,
756
+ narrowsOnTooFewClusters: false,
757
+ narrowsBySplitting: false,
758
+ findsHiddenCorrelation: false,
759
+ verdict:
760
+ `the declared key collapses ${f.clusters} case-file groups into ${d.clusters}, ${d.degenerate}. ` +
761
+ "No suite-level interval is estimable under this grouping, so it is not a tighter claim " +
762
+ "than the default; it is no claim at all",
763
+ };
764
+ }
765
+
766
+ // "Materially positive" rather than "> 0": the ANOVA ICC estimator is noisy
767
+ // and routinely lands slightly above zero on independent data, so a test at
768
+ // exactly zero would call every grouping useful.
769
+ const ICC_FLOOR = 0.05;
770
+ const coarser = d.clusters < f.clusters;
771
+ const mergesUncorrelatedCases = coarser && d.iccEstimable && d.icc < ICC_FLOOR;
772
+ const narrowsOnTooFewClusters =
773
+ mergesUncorrelatedCases && d.widthRatio < f.widthRatio && d.clusters < MIN_TRUSTWORTHY_CLUSTERS;
774
+ // The other direction. Both flags above require `coarser`, so a key that
775
+ // *splits* the case files apart fell through every branch to "it is not
776
+ // changing the answer either way", on a grouping that had just taken the
777
+ // interval from 3.90× the iid one to 1.02×. Gated on the fallback showing
778
+ // real correlation: when the case files do not move together anyway,
779
+ // splitting them costs nothing and there is nothing to warn about.
780
+ //
781
+ // Sharing ICC_FLOOR with `mergesUncorrelatedCases` also shares its noise. On
782
+ // 8 families of 10 this fires on every seed where the families really move
783
+ // and on 0.195 of the seeds where they do not; `cluster.test.ts` costs both
784
+ // out. That asymmetry is the one to have: the flag errs toward asking an
785
+ // operator to look again at an interval that got narrower, which is the
786
+ // question this module exists to make people ask.
787
+ const narrowsBySplitting =
788
+ d.clusters > f.clusters && f.iccEstimable && f.icc >= ICC_FLOOR && d.widthRatio < f.widthRatio;
789
+ // `iccEstimable` first: a grouping of singletons reports ICC exactly 1 as an
790
+ // artifact of σ̂² = 0, which sailed past this floor and could announce that a
791
+ // one-case-per-cluster key had "found correlation the case ids could not
792
+ // see". One case per cluster is the absence of within-cluster correlation.
793
+ const findsHiddenCorrelation =
794
+ d.iccEstimable && d.icc >= ICC_FLOOR && d.designEffect > f.designEffect * 1.1;
795
+
796
+ // Quote the *interval width*, not the design effect. They do not move
797
+ // together: a coarse grouping with near-zero ICC can have a design effect
798
+ // below 1 and still produce a much wider interval, because 4 clusters buy 3
799
+ // degrees of freedom and a t-critical to match. The first draft of this line
800
+ // said "widening the interval (0.11× vs 0.91×)", which reads as narrowing.
801
+ const widths = `its interval is ${d.widthRatio.toFixed(2)}× the iid one against ${f.widthRatio.toFixed(2)}× for the case-file default`;
802
+ // "ICC 1.00" is what a singleton grouping prints, and it says the cases are
803
+ // perfectly correlated when the grouping is the statement that they are not
804
+ // correlated at all. Say the number is missing rather than inventing one.
805
+ const icc = (g: GroupingSummary) => (g.iccEstimable ? g.icc.toFixed(2) : "not identified");
806
+ const verdict = narrowsOnTooFewClusters
807
+ ? `the declared key merges ${f.clusters} case-file groups into ${d.clusters}, and those ${d.clusters} show ` +
808
+ `ICC ${icc(d)}, they do not move together, so ${widths}. That is the wrong direction: ` +
809
+ `a cluster-robust interval over fewer than ${MIN_TRUSTWORTHY_CLUSTERS} clusters is optimistic, and this key has ` +
810
+ `made the suite-level claim *tighter* than the default on cases that share nothing`
811
+ : mergesUncorrelatedCases
812
+ ? `the declared key merges ${f.clusters} case-file groups into ${d.clusters}, and those ${d.clusters} show ` +
813
+ `ICC ${icc(d)}, they do not move together, so ${widths}, and the extra width is ` +
814
+ `buying no information`
815
+ : narrowsBySplitting
816
+ ? `the declared key splits ${f.clusters} case-file groups into ${d.clusters}, but those ${f.clusters} files ` +
817
+ `show ICC ${icc(f)}, they do move together, so ${widths}. The split is a claim that the suite is worth ` +
818
+ `~${Math.round(d.effectiveSampleSize)} independent cases rather than ~${Math.round(f.effectiveSampleSize)}, ` +
819
+ "and it buys a narrower suite-level interval than the default with nothing behind it"
820
+ : findsHiddenCorrelation
821
+ ? `the declared key finds correlation the case ids could not see: design effect ` +
822
+ `${d.designEffect.toFixed(2)} against ${f.designEffect.toFixed(2)} over ${d.clusters} groups rather than ` +
823
+ `${f.clusters} (ICC ${icc(d)} within the declared groups, ${icc(f)} within case files). ` +
824
+ `The suite is worth ~${Math.round(d.effectiveSampleSize)} independent cases, not ${Math.round(f.effectiveSampleSize)}` +
825
+ (d.clusters < MIN_TRUSTWORTHY_CLUSTERS
826
+ ? `. Treat the interval as a lower bound on the width: ${d.clusters} clusters is below the ${MIN_TRUSTWORTHY_CLUSTERS} ` +
827
+ "a cluster-robust estimator needs to be trusted"
828
+ : "")
829
+ : `the declared key and the case-file default agree to within ` +
830
+ `${((Math.abs(d.designEffect - f.designEffect) / Math.max(1e-9, f.designEffect)) * 100).toFixed(0)}% on the design effect ` +
831
+ `(ICC ${icc(d)} vs ${icc(f)}), it is not changing the answer either way`;
832
+
833
+ return {
834
+ declared: d,
835
+ fallback: f,
836
+ mergesUncorrelatedCases,
837
+ narrowsOnTooFewClusters,
838
+ narrowsBySplitting,
839
+ findsHiddenCorrelation,
840
+ verdict,
841
+ };
842
+ }