Repository navigation
Expand file tree
/
Copy pathengine.js
More file actions
1704 lines (1585 loc) · 70.2 KB
/
Copy pathengine.js
File metadata and controls
1704 lines (1585 loc) · 70.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Study planner calculation engine.
*
* Implements docs/methods.md. Every function names the methods.md section it
* implements. No interface code lives here. Works as a plain browser script
* (exposes globalThis.StudyEngine) and as a Node module (module.exports).
*/
(function (root, factory) {
'use strict';
const engine = factory();
if (typeof module === 'object' && module.exports) {
module.exports = engine;
} else {
root.StudyEngine = engine;
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
// ---------------------------------------------------------------------------
// Errors and small helpers
// ---------------------------------------------------------------------------
/**
* Raised for any input the methods do not allow. Messages are plain language.
* field names the input that caused it (e.g. 'delta', 'k', 'sd', 'psi');
* measure, comparison and factor say where, when they apply. Any of them may
* be null.
*/
class EngineError extends Error {
constructor(message, info) {
super(message);
this.name = 'EngineError';
const i = info || {};
this.field = i.field === undefined ? null : i.field;
this.measure = i.measure === undefined ? null : i.measure;
this.comparison = i.comparison === undefined ? null : i.comparison;
this.factor = i.factor === undefined ? null : i.factor;
}
}
// Raise an input error. Used by every section of methods.md.
function fail(message, info) {
throw new EngineError(message, infoOf(info));
}
// Run fn; if it raises an EngineError, fill in where it happened (measure,
// comparison) without overwriting anything already set. Used by plan().
function withWhere(where, fn) {
try {
return fn();
} catch (err) {
if (err instanceof EngineError) {
Object.keys(where).forEach((key) => {
if (err[key] === null) err[key] = where[key];
});
}
throw err;
}
}
// Normalize the info argument: a field name or an info object.
function infoOf(info) {
return typeof info === 'string' ? { field: info } : info;
}
// Input checks shared by every section of methods.md.
function requireNumber(value, name, info) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
fail(`${name} must be a number.`, infoOf(info));
}
}
// Rates, alpha, power, p and P lie strictly between 0 and 1 (methods.md §3.2, §4, §9).
function requireOpenUnit(value, name, info) {
requireNumber(value, name, info);
if (value <= 0 || value >= 1) fail(`${name} must be between 0 and 1, not ${value}.`, infoOf(info));
}
// SDs, n and df must be positive (methods.md §3.1, §4).
function requirePositive(value, name, info) {
requireNumber(value, name, info);
if (value <= 0) fail(`${name} must be greater than 0.`, infoOf(info));
}
// methods.md Conventions: sample sizes are always rounded up. The small
// tolerance stops floating-point noise (e.g. 200.00000000000003) from adding
// a participant.
function ceilN(x) {
return Math.ceil(x - 1e-9);
}
// Bisection for a monotone function f on [lo, hi]; returns x with f(x) = target.
// Used to solve n (methods.md §4), t quantiles (§4) and detectable differences (§7).
function bisect(f, target, lo, hi, tol) {
let flo = f(lo) - target;
for (let i = 0; i < 400 && hi - lo > tol; i++) {
const mid = 0.5 * (lo + hi);
const fm = f(mid) - target;
if ((fm < 0) === (flo < 0)) {
lo = mid;
flo = fm;
} else {
hi = mid;
}
}
return 0.5 * (lo + hi);
}
// ---------------------------------------------------------------------------
// Normal distribution (methods.md §3.1 Φ and Φ⁻¹; §4 z quantiles)
// ---------------------------------------------------------------------------
// Standard normal CDF. W. J. Cody (1969), "Rational Chebyshev approximations
// for the error function", Mathematics of Computation 23, 631-637; the same
// algorithm and coefficients as R's pnorm (nmath/pnorm.c).
const PN_A = [2.2352520354606839287, 161.02823106855587881, 1067.6894854603709582,
18154.981253343561249, 0.065682337918207449113];
const PN_B = [47.20258190468824187, 976.09855173777669322, 10260.932208618978205,
45507.789335026729956];
const PN_C = [0.39894151208813466764, 8.8831497943883759412, 93.506656132177855979,
597.27027639480026226, 2494.5375852903726711, 6848.1904505362823326,
11602.651437647350124, 9842.7148383839780218, 1.0765576773720192317e-8];
const PN_D = [22.266688044328115691, 235.38790178262499861, 1519.377599407554805,
6485.558298266760755, 18615.571640885098091, 34900.952721145977266,
38912.003286093271411, 19685.429676859990727];
const PN_P = [0.21589853405795699, 0.1274011611602473639, 0.022235277870649807,
0.001421619193227893466, 2.9112874951168792e-5, 0.02307344176494017303];
const PN_Q = [1.28426009614491121, 0.468238212480865118, 0.0659881378689285515,
0.00378239633202758244, 7.29751555083966205e-5];
const SQRT_32 = 5.656854249492380195206754896838;
const ONE_OVER_SQRT_2PI = 0.398942280401432677939946059934;
/** Standard normal CDF Φ(x). methods.md §3.1, §4, §7. */
function normalCdf(x) {
if (Number.isNaN(x)) return NaN;
if (x === Infinity) return 1;
if (x === -Infinity) return 0;
const y = Math.abs(x);
if (y <= 0.67448975) {
let xnum = 0;
let xden = 0;
if (y > 1.1102230246251565e-16) {
const xsq = x * x;
xnum = PN_A[4] * xsq;
xden = xsq;
for (let i = 0; i < 3; i++) {
xnum = (xnum + PN_A[i]) * xsq;
xden = (xden + PN_B[i]) * xsq;
}
}
const temp = (x * (xnum + PN_A[3])) / (xden + PN_B[3]);
return 0.5 + temp;
}
let temp;
if (y <= SQRT_32) {
let xnum = PN_C[8] * y;
let xden = y;
for (let i = 0; i < 7; i++) {
xnum = (xnum + PN_C[i]) * y;
xden = (xden + PN_D[i]) * y;
}
temp = (xnum + PN_C[7]) / (xden + PN_D[7]);
} else {
const xsq = 1 / (x * x);
let xnum = PN_P[5] * xsq;
let xden = xsq;
for (let i = 0; i < 4; i++) {
xnum = (xnum + PN_P[i]) * xsq;
xden = (xden + PN_Q[i]) * xsq;
}
temp = (xsq * (xnum + PN_P[4])) / (xden + PN_Q[4]);
temp = (ONE_OVER_SQRT_2PI - temp) / y;
}
// Split exp(-y^2/2) to keep precision (Cody's "do_del").
const xsq = Math.trunc(y * 16) / 16;
const del = (y - xsq) * (y + xsq);
const cum = Math.exp(-xsq * xsq * 0.5) * Math.exp(-del * 0.5) * temp;
return x > 0 ? 1 - cum : cum;
}
/**
* Standard normal quantile Φ⁻¹(p). methods.md §3.1, §4.
* M. J. Wichura (1988), Algorithm AS 241 (PPND16), Applied Statistics 37,
* 477-484; the algorithm behind R's qnorm.
*/
function normalQuantile(p) {
requireNumber(p, 'Probability');
if (p <= 0 || p >= 1) {
if (p === 0) return -Infinity;
if (p === 1) return Infinity;
fail(`Probability must be between 0 and 1, not ${p}.`);
}
const q = p - 0.5;
if (Math.abs(q) <= 0.425) {
const r = 0.180625 - q * q;
return (q * (((((((r * 2509.0809287301226727 + 33430.575583588128105) * r +
67265.770927008700853) * r + 45921.953931549871457) * r +
13731.693765509461125) * r + 1971.5909503065514427) * r +
133.14166789178437745) * r + 3.387132872796366608)) /
(((((((r * 5226.495278852545925 + 28729.085735721942674) * r +
39307.89580009271061) * r + 21213.794301586595867) * r +
5394.1960214247511077) * r + 687.1870074920579083) * r +
42.313330701600911252) * r + 1);
}
let r = q < 0 ? p : 1 - p;
r = Math.sqrt(-Math.log(r));
let val;
if (r <= 5) {
r -= 1.6;
val = (((((((r * 7.7454501427834140764e-4 + 0.0227238449892691845833) * r +
0.24178072517745061177) * r + 1.27045825245236838258) * r +
3.64784832476320460504) * r + 5.7694972214606914055) * r +
4.6303378461565452959) * r + 1.42343711074968357734) /
(((((((r * 1.05075007164441684324e-9 + 5.475938084995344946e-4) * r +
0.0151986665636164571966) * r + 0.14810397642748007459) * r +
0.68976733498510000455) * r + 1.6763848301838038494) * r +
2.05319162663775882187) * r + 1);
} else {
r -= 5;
val = (((((((r * 2.01033439929228813265e-7 + 2.71155556874348757815e-5) * r +
0.0012426609473880784386) * r + 0.026532189526576123093) * r +
0.29656057182850489123) * r + 1.7848265399172913358) * r +
5.4637849111641143699) * r + 6.6579046435011037772) /
(((((((r * 2.04426310338993978564e-15 + 1.4215117583164458887e-7) * r +
1.8463183175100546818e-5) * r + 7.868691311456132591e-4) * r +
0.0148753612908506148525) * r + 0.13692988092273580531) * r +
0.59983220655588793769) * r + 1);
}
return q < 0 ? -val : val;
}
// ---------------------------------------------------------------------------
// Gamma and incomplete beta (support for §4 and §7 t distributions)
// ---------------------------------------------------------------------------
// Lanczos approximation as given in W. H. Press, S. A. Teukolsky,
// W. T. Vetterling & B. P. Flannery (2007), Numerical Recipes, 3rd ed.,
// section 6.1 (gammln). Relative accuracy about 1e-15.
const LANCZOS = [57.1562356658629235, -59.5979603554754912, 14.1360979747417471,
-0.491913816097620199, 0.339946499848118887e-4, 0.465236289270485756e-4,
-0.983744753048795646e-4, 0.158088703224912494e-3, -0.210264441724104883e-3,
0.217439618115212643e-3, -0.164318106536763890e-3, 0.844182239838527433e-4,
-0.261908384015814087e-4, 0.368991826595316234e-5];
/** log Γ(x) for x > 0. Supports methods.md §4 (t distributions). */
function logGamma(x) {
if (!(x > 0)) fail('logGamma needs a positive argument.');
let y = x;
let tmp = x + 5.2421875;
tmp = (x + 0.5) * Math.log(tmp) - tmp;
let ser = 0.999999999999997092;
for (let j = 0; j < 14; j++) ser += LANCZOS[j] / ++y;
return tmp + Math.log((2.5066282746310005 * ser) / x);
}
// Continued fraction for the incomplete beta, evaluated with the modified
// Lentz method: Press et al. (2007), Numerical Recipes, 3rd ed., section 6.4
// (betacf).
// Supports methods.md §4 (t distributions).
function betaContinuedFraction(a, b, x) {
const FPMIN = 1e-300;
const EPS = 1e-16;
const qab = a + b;
const qap = a + 1;
const qam = a - 1;
let c = 1;
let d = 1 - (qab * x) / qap;
if (Math.abs(d) < FPMIN) d = FPMIN;
d = 1 / d;
let h = d;
for (let m = 1; m <= 10000; m++) {
const m2 = 2 * m;
let aa = (m * (b - m) * x) / ((qam + m2) * (a + m2));
d = 1 + aa * d;
if (Math.abs(d) < FPMIN) d = FPMIN;
c = 1 + aa / c;
if (Math.abs(c) < FPMIN) c = FPMIN;
d = 1 / d;
h *= d * c;
aa = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2));
d = 1 + aa * d;
if (Math.abs(d) < FPMIN) d = FPMIN;
c = 1 + aa / c;
if (Math.abs(c) < FPMIN) c = FPMIN;
d = 1 / d;
const del = d * c;
h *= del;
if (Math.abs(del - 1) <= EPS) break;
}
return h;
}
/**
* Regularized incomplete beta I_x(a, b). Supports methods.md §4.
* Press et al. (2007), Numerical Recipes, 3rd ed., section 6.4 (betai),
* using the symmetry I_x(a,b) = 1 − I_{1−x}(b,a) for fast convergence.
*/
function incompleteBeta(x, a, b) {
if (!(a > 0) || !(b > 0)) fail('Incomplete beta needs positive shape values.');
if (x <= 0) return 0;
if (x >= 1) return 1;
const lbt = logGamma(a + b) - logGamma(a) - logGamma(b) +
a * Math.log(x) + b * Math.log1p(-x);
const bt = Math.exp(lbt);
if (x < (a + 1) / (a + b + 2)) return (bt * betaContinuedFraction(a, b, x)) / a;
return 1 - (bt * betaContinuedFraction(b, a, 1 - x)) / b;
}
// ---------------------------------------------------------------------------
// t distribution (methods.md §4, §7)
// ---------------------------------------------------------------------------
/** Central t CDF, P(T ≤ t) with df degrees of freedom. methods.md §4, §7. */
function tCdf(t, df) {
requirePositive(df, 'Degrees of freedom');
if (t === Infinity) return 1;
if (t === -Infinity) return 0;
if (t === 0) return 0.5;
const tail = 0.5 * incompleteBeta(df / (df + t * t), df / 2, 0.5);
return t > 0 ? 1 - tail : tail;
}
/** t quantile, solved numerically from tCdf. methods.md §4, §7. */
function tQuantile(p, df) {
requireOpenUnit(p, 'Probability');
requirePositive(df, 'Degrees of freedom');
if (p === 0.5) return 0;
// Work in the upper half, then reflect.
const upper = p > 0.5;
const target = upper ? p : 1 - p;
let hi = 1;
while (tCdf(hi, df) < target) hi *= 2;
const t = bisect((x) => tCdf(x, df), target, 0, hi, 1e-13 * Math.max(1, hi));
return upper ? t : -t;
}
/**
* Noncentral t CDF, P(T′ ≤ t) with df degrees of freedom and noncentrality
* ncp. methods.md §4. R. V. Lenth (1989), Algorithm AS 243, Applied
* Statistics 38, 185-189, with the refinements in R's pnt.c that pt() uses:
* errmax 1e-12, itrmax 1000, reflection for t < 0, the central t when
* ncp = 0, and the Abramowitz & Stegun 26.7.10 normal approximation for
* very large df or ncp.
*/
function noncentralTCdf(t, df, ncp) {
requirePositive(df, 'Degrees of freedom');
requireNumber(ncp, 'Noncentrality');
if (ncp === 0) return tCdf(t, df);
if (t === Infinity) return 1;
if (t === -Infinity) return 0;
const ITRMAX = 1000;
const ERRMAX = 1e-12;
let negdel;
let tt;
let del;
if (t >= 0) {
negdel = false;
tt = t;
del = ncp;
} else {
if (ncp > 40) return 0;
negdel = true;
tt = -t;
del = -ncp;
}
// 2 * ln 2 * 1021 (DBL_MIN_EXP = -1021): beyond this exp(-del²/2) underflows.
if (df > 4e5 || del * del > 2 * Math.LN2 * 1021) {
const s = 1 / (4 * df);
const z = (tt * (1 - s) - del) / Math.sqrt(1 + tt * tt * 2 * s);
const lower = normalCdf(z);
return negdel ? 1 - lower : lower;
}
let x = tt * tt;
let rxb = df / (x + df);
x = x / (x + df);
let tnc;
if (x > 0) {
const lambda = del * del;
let p = 0.5 * Math.exp(-0.5 * lambda);
if (p === 0) return negdel ? 1 : 0;
let q = Math.sqrt(2 / Math.PI) * p * del;
let s = 0.5 - p;
if (s < 1e-7) s = -0.5 * Math.expm1(-0.5 * lambda);
let a = 0.5;
const b = 0.5 * df;
rxb = Math.pow(rxb, b);
const albeta = 0.5 * Math.log(Math.PI) + logGamma(b) - logGamma(0.5 + b);
let xodd = incompleteBeta(x, a, b);
let godd = 2 * rxb * Math.exp(a * Math.log(x) - albeta);
tnc = b * x;
let xeven = tnc < Number.EPSILON ? tnc : 1 - rxb;
let geven = tnc * rxb;
tnc = p * xodd + q * xeven;
for (let it = 1; it <= ITRMAX; it++) {
a += 1;
xodd -= godd;
xeven -= geven;
godd *= (x * (a + b - 1)) / a;
geven *= (x * (a + b - 0.5)) / (a + 0.5);
p *= lambda / (2 * it);
q *= lambda / (2 * it + 1);
tnc += p * xodd + q * xeven;
s -= p;
if (s < -1e-10) break;
if (s <= 0 && it > 1) break;
const errbd = 2 * s * (xodd - godd);
if (Math.abs(errbd) < ERRMAX) break;
}
} else {
tnc = 0;
}
tnc += normalCdf(-del);
tnc = Math.min(tnc, 1);
return negdel ? 1 - tnc : tnc;
}
// ---------------------------------------------------------------------------
// Sample size and power (methods.md §4, §2.1)
// ---------------------------------------------------------------------------
// Alpha and power are probabilities (methods.md Conventions, §4).
function requireAlphaPower(alpha, power) {
requireOpenUnit(alpha, 'Alpha', 'alpha');
if (power !== undefined) requireOpenUnit(power, 'Power', 'power');
}
// methods.md §4 "Power, t-tests": two-sided power counts both rejection
// regions, P(T′ > t_c) + P(T′ < −t_c).
function twoSidedTPower(df, ncp, alpha) {
const tc = tQuantile(1 - alpha / 2, df);
const absNcp = Math.abs(ncp);
return (1 - noncentralTCdf(tc, df, absNcp)) + noncentralTCdf(-tc, df, absNcp);
}
/** Power of the paired t-test: df = n − 1, ncp = dz·√n. methods.md §4. */
function powerPairedT(n, dz, alpha) {
requireNumber(dz, 'Effect size (dz)', 'delta');
requireAlphaPower(alpha);
if (!(n > 1)) fail('The paired t-test needs at least 2 participants.', 'participants');
return twoSidedTPower(n - 1, dz * Math.sqrt(n), alpha);
}
/**
* Power of the independent t-test, n per condition: df = 2(n − 1),
* ncp = d·√(n/2). methods.md §4.
*/
function powerIndependentT(n, d, alpha) {
requireNumber(d, 'Effect size (d)', 'delta');
requireAlphaPower(alpha);
if (!(n > 1)) fail('The independent t-test needs at least 2 participants per condition.', 'participants');
return twoSidedTPower(2 * (n - 1), d * Math.sqrt(n / 2), alpha);
}
// Solve continuous n where power reaches the target, then round up (methods.md §4).
function solveN(powerFn, effect, alpha, power, label) {
requireAlphaPower(alpha, power);
requireNumber(effect, label, 'delta');
if (effect === 0) fail('The difference that matters must not be zero.', 'delta');
const f = (n) => powerFn(n, effect, alpha);
if (f(2) >= power) return { exact: 2, n: 2 };
let hi = 4;
while (f(hi) < power) {
hi *= 2;
if (hi > 1e7) fail('The difference is too small to detect with any practical sample.', 'delta');
}
const exact = bisect(f, power, 2, hi, 1e-10);
return { exact, n: ceilN(exact) };
}
/** Participants for the paired t-test (continuous and rounded up). methods.md §4. */
function nPairedT(dz, alpha, power) {
return solveN(powerPairedT, dz, alpha, power, 'Effect size (dz)');
}
/** Participants per condition for the independent t-test. methods.md §4. */
function nIndependentT(d, alpha, power) {
return solveN(powerIndependentT, d, alpha, power, 'Effect size (d)');
}
/** Cohen's h = 2·arcsin(√p₁) − 2·arcsin(√p₂). methods.md §4. */
function cohensH(p1, p2) {
requireOpenUnit(p1, 'Success rate 1', 'p1');
requireOpenUnit(p2, 'Success rate 2', 'p2');
return 2 * Math.asin(Math.sqrt(p1)) - 2 * Math.asin(Math.sqrt(p2));
}
// methods.md §4 and §7: two-sided power counts both rejection regions, as
// pwr.2p.test does: Φ(|h|·√(n/2) − z₁₋α/₂) + Φ(−|h|·√(n/2) − z₁₋α/₂).
function twoRegionProportionPower(absH, n, za) {
const shift = absH * Math.sqrt(n / 2);
return normalCdf(shift - za) + normalCdf(-shift - za);
}
/**
* n per condition where the two-region power reaches the target, solved as a
* continuous value. methods.md §4. The one-region closed form
* 2·((z₁₋α/₂ + z₁₋β)/h)² is never below it, so it bounds the search.
*/
function nTwoProportions(p1, p2, alpha, power) {
requireAlphaPower(alpha, power);
const h = cohensH(p1, p2);
if (h === 0) fail('The two success rates must differ.', 'p2');
const za = normalQuantile(1 - alpha / 2);
const z = za + normalQuantile(power);
const closedForm = 2 * (z / h) * (z / h);
const exact = bisect((n) => twoRegionProportionPower(Math.abs(h), n, za), power, 0, closedForm, 1e-10);
return { h, exact, n: ceilN(exact) };
}
/** Power of the two-proportion test at n per condition. methods.md §7. */
function powerTwoProportions(n, p1, p2, alpha) {
requireAlphaPower(alpha);
requirePositive(n, 'Participants', 'participants');
const h = Math.abs(cohensH(p1, p2));
return twoRegionProportionPower(h, n, normalQuantile(1 - alpha / 2));
}
/** Default discordance ψ = p₁(1 − p₂) + p₂(1 − p₁). methods.md §3.2. */
function defaultDiscordance(p1, p2) {
requireOpenUnit(p1, 'Success rate 1', 'p1');
requireOpenUnit(p2, 'Success rate 2', 'p2');
return p1 * (1 - p2) + p2 * (1 - p1);
}
// methods.md §3.2: an entered ψ must be at least |p₂ − p₁|, and no more than
// the two rates allow.
function checkDiscordance(p1, p2, psi) {
requireOpenUnit(psi, 'Share who differ between conditions (discordance)', 'psi');
const delta = Math.abs(p2 - p1);
if (psi < delta - 1e-12) {
fail(`The share who differ between conditions (discordance) must be at least ` +
`the difference in success rates (${round(delta, 4)}).`, 'psi');
}
const maxPsi = Math.min(p1, 1 - p2) + Math.min(p2, 1 - p1);
if (psi > maxPsi + 1e-12) {
fail(`With success rates of ${p1} and ${p2}, the share who differ between ` +
`conditions (discordance) can be at most ${round(maxPsi, 4)}.`, 'psi');
}
}
/**
* McNemar's test (Connor, 1987): n = [z₁₋α/₂·√ψ + z₁₋β·√(ψ − δ²)]² / δ².
* psi defaults to defaultDiscordance(p1, p2). methods.md §3.2, §4.
*/
function nMcNemar(p1, p2, alpha, power, psi) {
requireAlphaPower(alpha, power);
requireOpenUnit(p1, 'Success rate 1', 'p1');
requireOpenUnit(p2, 'Success rate 2', 'p2');
const delta = p2 - p1;
if (delta === 0) fail('The two success rates must differ.', 'p2');
const usedPsi = psi === undefined ? defaultDiscordance(p1, p2) : psi;
checkDiscordance(p1, p2, usedPsi);
const za = normalQuantile(1 - alpha / 2);
const zb = normalQuantile(power);
const num = za * Math.sqrt(usedPsi) + zb * Math.sqrt(usedPsi - delta * delta);
const exact = (num * num) / (delta * delta);
return { psi: usedPsi, delta, exact, n: ceilN(exact) };
}
/** Power of McNemar's test at n participants (Connor solved for power). methods.md §7. */
function powerMcNemar(n, p1, p2, alpha, psi) {
requireAlphaPower(alpha);
requirePositive(n, 'Participants', 'participants');
const delta = Math.abs(p2 - p1);
const usedPsi = psi === undefined ? defaultDiscordance(p1, p2) : psi;
checkDiscordance(p1, p2, usedPsi);
const za = normalQuantile(1 - alpha / 2);
return normalCdf((delta * Math.sqrt(n) - za * Math.sqrt(usedPsi)) /
Math.sqrt(usedPsi - delta * delta));
}
/** Asymptotic relative efficiency of rank-based tests, 3/π. methods.md §2.1. */
const RANK_EFFICIENCY = 3 / Math.PI;
/** Rank-based adjustment: ceil(n ÷ (3/π)), n already rounded up. methods.md §2.1. */
function rankAdjust(n) {
requirePositive(n, 'Participants', 'participants');
return ceilN(n / RANK_EFFICIENCY);
}
// ---------------------------------------------------------------------------
// Comparisons and alpha (methods.md §1.1, §1.2, §1.3, §1.5)
// ---------------------------------------------------------------------------
/**
* m for "every pair": k(k − 1)/2. methods.md §1.2. scheme defaults to
* 'all-pairs'; "each vs. a baseline" is planned for a later version (§13).
*/
function countComparisons(k, scheme) {
if (!Number.isInteger(k) || k < 2) fail('A study needs at least 2 conditions.', 'levels');
const s = scheme === undefined ? 'all-pairs' : scheme;
if (s === 'all-pairs') return (k * (k - 1)) / 2;
if (s === 'baseline') fail('Comparing each condition with a baseline only is planned for a later version. In this version, every pair of conditions is compared.', 'comparisons');
fail(`Unknown comparison choice "${s}". Use "all-pairs".`, 'comparisons');
}
/** Bonferroni alpha per comparison: alpha / m. methods.md §1.3. */
function alphaPerComparison(alpha, m) {
requireOpenUnit(alpha, 'Alpha', 'alpha');
if (!Number.isInteger(m) || m < 1) fail('There must be at least one comparison.', 'comparisons');
return alpha / m;
}
// Assumption behind planning with Fisher's protected LSD (methods.md §1.3).
const LSD_ASSUMPTION = 'Planned assuming the overall test is significant; the true chance of finding a winner ' +
'is slightly lower. A simulation check is planned for version 1.1.';
/**
* Multiple-comparison procedure and the alpha each comparison is planned at.
* methods.md §1.3:
* - one comparison: 'single', alpha;
* - one independent variable with exactly three conditions: 'protected-lsd'
* (Fisher's protected LSD; Hayter, 1986), alpha;
* - otherwise: 'bonferroni-holm', alpha / m (Holm at analysis).
*/
function multipleComparisons(alpha, k, m, factorCount) {
requireOpenUnit(alpha, 'Alpha', 'alpha');
if (!Number.isInteger(k) || k < 2) fail('A study needs at least 2 conditions.', 'levels');
if (!Number.isInteger(m) || m < 1) fail('There must be at least one comparison.', 'comparisons');
if (m === 1) return { procedure: 'single', alphaPerComparison: alpha };
if (factorCount === 1 && k === 3) return { procedure: 'protected-lsd', alphaPerComparison: alpha };
return { procedure: 'bonferroni-holm', alphaPerComparison: alphaPerComparison(alpha, m) };
}
/** Every combination of levels across the factors, in order. methods.md §1.1. */
function buildCells(factors) {
if (!Array.isArray(factors) || factors.length === 0) {
fail('The study needs at least one independent variable.', 'factors');
}
factors.forEach((f) => {
if (!f || !Array.isArray(f.levels) || f.levels.length < 2) {
fail(`Each independent variable needs at least 2 conditions (${f && f.name}).`,
{ field: 'levels', factor: f ? f.name : null });
}
if (new Set(f.levels).size !== f.levels.length) {
fail(`Condition names must be unique within ${f.name}.`, { field: 'levels', factor: f.name });
}
});
let cells = [[]];
factors.forEach((f) => {
const next = [];
cells.forEach((c) => f.levels.forEach((l) => next.push(c.concat([l]))));
cells = next;
});
return cells;
}
/** Cells per level of one factor when the others are pooled. methods.md §1.5. */
function cellsPerLevel(factors, factorName) {
const idx = factors.findIndex((f) => f.name === factorName);
if (idx < 0) fail(`Unknown independent variable "${factorName}".`, { field: 'comparisons', factor: factorName });
return factors.reduce((acc, f, i) => (i === idx ? acc : acc * f.levels.length), 1);
}
// ---------------------------------------------------------------------------
// Variability and effect size (methods.md §3.1)
// ---------------------------------------------------------------------------
const CONSISTENCY_RHO = { low: 0.3, medium: 0.5, high: 0.7 };
/** Consistency across conditions → ρ. Default Low (the cautious choice). methods.md §3.1. */
function rhoFromConsistency(level) {
const key = level === undefined ? 'low' : level;
if (!Object.prototype.hasOwnProperty.call(CONSISTENCY_RHO, key)) {
fail(`Consistency must be "low", "medium", or "high", not "${level}".`, 'consistency');
}
return CONSISTENCY_RHO[key];
}
/** σ_diff = σ·√(2(1 − ρ)). methods.md §3.1. */
function sdDiffFromSd(sd, rho) {
requirePositive(sd, 'Standard deviation', 'sd');
requireNumber(rho, 'Correlation', 'consistency');
if (rho <= -1 || rho >= 1) fail('The correlation must be between -1 and 1.', 'consistency');
return sd * Math.sqrt(2 * (1 - rho));
}
// k of 10 is accepted only as a whole number from 6 to 9 (methods.md §3.1).
function requireK(k) {
if (!Number.isInteger(k) || k < 6 || k > 9) {
fail(`"How many of 10 participants would do better" must be a whole number from 6 to 9, not ${k}.`, 'k');
}
}
/** Within: σ_diff = Δ / Φ⁻¹(k/10). methods.md §3.1. */
function sdDiffFromK(delta, k) {
requirePositive(Math.abs(delta), 'The difference that matters', 'delta');
requireK(k);
return Math.abs(delta) / normalQuantile(k / 10);
}
/** Between: σ = Δ / (√2·Φ⁻¹(k/10)). methods.md §3.1. */
function sdFromK(delta, k) {
requirePositive(Math.abs(delta), 'The difference that matters', 'delta');
requireK(k);
return Math.abs(delta) / (Math.SQRT2 * normalQuantile(k / 10));
}
/** Within: dz = Φ⁻¹(k/10). methods.md §3.1. */
function dzFromK(k) {
requireK(k);
return normalQuantile(k / 10);
}
/** Between: d = √2·Φ⁻¹(k/10). methods.md §3.1. */
function dFromK(k) {
requireK(k);
return Math.SQRT2 * normalQuantile(k / 10);
}
/** "k of 10 do better" from dz: 10·Φ(dz). methods.md §7. */
function kFromDz(dz) {
return 10 * normalCdf(dz);
}
/** "k of 10 do better" from d: 10·Φ(d/√2). methods.md §7. */
function kFromD(d) {
return 10 * normalCdf(d / Math.SQRT2);
}
// ---------------------------------------------------------------------------
// Bounded scales (methods.md §2, §2.2)
// ---------------------------------------------------------------------------
/** Measure presets. methods.md §2. */
const PRESETS = {
'nasa-tlx': { name: 'NASA-TLX (raw)', type: 'rating', min: 0, max: 100 },
sus: { name: 'SUS', type: 'rating', min: 0, max: 100 },
seq: { name: 'SEQ', type: 'rating', min: 1, max: 7 },
};
/** Largest possible SD on [min, max] at a given mean (Bhatia & Davis, 2000). methods.md §2.2. */
function maxSdOnScale(mean, min, max) {
requireNumber(mean, 'Expected average', 'mean');
requireNumber(min, 'Scale minimum', 'scale');
requireNumber(max, 'Scale maximum', 'scale');
if (!(max > min)) fail('The scale maximum must be above the minimum.', 'scale');
if (mean < min || mean > max) fail(`The expected average must lie between ${min} and ${max}.`, 'mean');
return Math.sqrt((max - mean) * (mean - min));
}
/** Reject an SD above the Bhatia–Davis bound. methods.md §2.2. */
function checkBoundedSd(sd, mean, min, max) {
requirePositive(sd, 'Standard deviation', 'sd');
const bound = maxSdOnScale(mean, min, max);
if (sd > bound + 1e-12) {
fail(`On a ${min} to ${max} scale with an average of ${mean}, the standard deviation ` +
`can be at most ${round(bound, 2)}. ${sd} is not possible.`, 'sd');
}
return bound;
}
// ---------------------------------------------------------------------------
// Confidence report (methods.md §7)
// ---------------------------------------------------------------------------
// Detectable difference: effect size where power reaches the target (methods.md §7).
function solveEffect(powerAt, target, hiStart) {
let hi = hiStart;
while (powerAt(hi) < target) {
hi *= 2;
if (hi > 1e6) return null;
}
return bisect(powerAt, target, 0, hi, 1e-12);
}
/**
* Confidence report for a t-based measure. methods.md §7.
* opts: { design: 'within' | 'between', n (participants within, per condition
* between), alpha, power, rank (bool), effectSize (dz or d, the target), and
* for results in units: delta and sd (σ_diff within, σ between) }.
* Rank-based measures use effective n = n × 3/π for power and detectable
* difference, and the actual n for precision.
*/
function confidenceT(opts) {
const { design, n, alpha } = opts;
const target = opts.power === undefined ? 0.8 : opts.power;
requireAlphaPower(alpha, target);
if (design !== 'within' && design !== 'between') fail('Design must be "within" or "between".', 'design');
if (!(n >= 2)) fail('The confidence report needs at least 2 participants.', 'participants');
const nEff = opts.rank ? n * RANK_EFFICIENCY : n;
const powerFn = design === 'within' ? powerPairedT : powerIndependentT;
if (!(nEff > 1)) fail('Too few participants for the rank-based test.', 'participants');
const effectSize = opts.effectSize;
const result = { n, effectiveN: nEff };
result.power = effectSize === undefined ? null : powerFn(nEff, Math.abs(effectSize), alpha);
const es = solveEffect((e) => powerFn(nEff, e, alpha), target, 1);
const hasUnits = opts.sd !== undefined;
result.detectable = {
effectSize: es,
units: hasUnits && es !== null ? es * opts.sd : null,
kOf10: es === null ? null : design === 'within' ? kFromDz(es) : kFromD(es),
};
if (hasUnits) {
requirePositive(opts.sd, 'Standard deviation', 'sd');
const df = design === 'within' ? n - 1 : 2 * (n - 1);
const se = design === 'within' ? opts.sd / Math.sqrt(n) : opts.sd * Math.sqrt(2 / n);
result.precision = { halfWidth: tQuantile(0.975, df) * se, se, df };
} else {
result.precision = null;
}
return result;
}
/**
* Confidence report for a success / failure measure. methods.md §7.
* opts: { design: 'within' (McNemar) | 'between' (two proportions), n
* (participants within, per condition between), p1, p2, psi (optional,
* within only), alpha, power }.
* Detectable difference: p₁ fixed, smallest p₂ in the entered direction that
* reaches the chosen power, in percentage points.
*/
function confidenceBinary(opts) {
const { design, n, p1, p2, alpha } = opts;
const target = opts.power === undefined ? 0.8 : opts.power;
requireAlphaPower(alpha, target);
requireOpenUnit(p1, 'Success rate 1', 'p1');
requireOpenUnit(p2, 'Success rate 2', 'p2');
requirePositive(n, 'Participants', 'participants');
if (p1 === p2) fail('The two success rates must differ.', 'p2');
const dir = p2 > p1 ? 1 : -1;
const fixedPsi = opts.psi;
let powerAtP2;
let halfWidth;
const z975 = normalQuantile(0.975);
if (design === 'between') {
powerAtP2 = (q2) => powerTwoProportions(n, p1, q2, alpha);
halfWidth = z975 * Math.sqrt((p1 * (1 - p1)) / n + (p2 * (1 - p2)) / n);
} else if (design === 'within') {
const psiFor = (q2) => (fixedPsi === undefined ? defaultDiscordance(p1, q2) : fixedPsi);
powerAtP2 = (q2) => {
const psi = psiFor(q2);
const d = Math.abs(q2 - p1);
if (psi - d * d <= 0 || psi <= d) return 1;
return normalCdf((d * Math.sqrt(n) - zTwoSided(alpha) * Math.sqrt(psi)) /
Math.sqrt(psi - d * d));
};
const psi = psiFor(p2);
checkDiscordance(p1, p2, psi);
const delta = p2 - p1;
halfWidth = z975 * Math.sqrt((psi - delta * delta) / n);
} else {
fail('Design must be "within" or "between".', 'design');
}
// Search p₂ between p₁ and the end of the scale in the entered direction.
// With a fixed ψ, |p₂ − p₁| must stay below ψ.
let limit = dir > 0 ? 1 - 1e-9 : 1e-9;
if (design === 'within' && fixedPsi !== undefined) {
limit = dir > 0 ? Math.min(limit, p1 + fixedPsi - 1e-9) : Math.max(limit, p1 - fixedPsi + 1e-9);
}
const powerAtDiff = (d) => powerAtP2(p1 + dir * d);
const maxDiff = Math.abs(limit - p1);
let detectableP2 = null;
if (powerAtDiff(maxDiff) >= target) {
const d = bisect(powerAtDiff, target, 0, maxDiff, 1e-12);
detectableP2 = p1 + dir * d;
}
const power = design === 'between'
? powerTwoProportions(n, p1, p2, alpha)
: powerMcNemar(n, p1, p2, alpha, fixedPsi);
return {
n,
power,
detectable: detectableP2 === null ? null : {
p2: detectableP2,
percentagePoints: 100 * Math.abs(detectableP2 - p1),
},
precision: { halfWidth, percentagePoints: 100 * halfWidth },
};
}
// z₁₋α/₂ for the two-sided test (methods.md §4, §7).
function zTwoSided(alpha) {
return normalQuantile(1 - alpha / 2);
}
// ---------------------------------------------------------------------------
// Counterbalancing (methods.md §6)
// ---------------------------------------------------------------------------
/**
* Balanced Latin square orders (Williams, 1949) for k conditions, as arrays
* of condition indices. k orders when k is even, 2k when k is odd (the
* square plus its mirror image). Two conditions give two orders. methods.md §6.
*/
function williamsSquare(k) {
if (!Number.isInteger(k) || k < 2) fail('Counterbalancing needs at least 2 conditions.', 'levels');
// First row: 0, 1, k−1, 2, k−2, ...
const first = [0];
for (let i = 1, lo = 1, hi = k - 1; i < k; i++) {
first.push(i % 2 === 1 ? lo++ : hi--);
}
const rows = [];
for (let r = 0; r < k; r++) rows.push(first.map((c) => (c + r) % k));
if (k % 2 === 1) {
for (let r = 0; r < k; r++) rows.push(rows[r].slice().reverse());
}
return rows;
}
/** Number of counterbalancing orders: k (even k) or 2k (odd k). methods.md §6. */
function orderCount(k) {
if (!Number.isInteger(k) || k < 2) fail('Counterbalancing needs at least 2 conditions.', 'levels');
return k % 2 === 0 ? k : 2 * k;
}
/** Round n up to a multiple of the number of orders. methods.md §6. */
function roundToOrders(n, orders) {
requirePositive(n, 'Participants', 'participants');
if (!Number.isInteger(orders) || orders < 1) fail('The number of orders must be a whole number.', 'levels');
return Math.ceil(n / orders) * orders;
}
// ---------------------------------------------------------------------------
// Problem discovery (methods.md §9)
// ---------------------------------------------------------------------------
/**
* n = ln(1 − P) / ln(1 − p), rounded up. methods.md §9.
* Also returns the trail's terms: missOne = 1 − p (one participant misses
* the problem) and missAll = 1 − P (the largest allowed chance that all miss it).
*/
function nProblemDiscovery(p, P) {
requireOpenUnit(p, 'Problem frequency (p)', 'p');
requireOpenUnit(P, 'Chance of seeing the problem (P)', 'P');
const exact = Math.log1p(-P) / Math.log1p(-p);
return { exact, n: ceilN(exact), missOne: 1 - p, missAll: 1 - P };
}
/** Chance of seeing a problem at least once: 1 − (1 − p)ⁿ. methods.md §9. */
function chanceOfSeeing(p, n) {
requireOpenUnit(p, 'Problem frequency (p)', 'p');
if (!(n >= 0)) fail('Participants must be zero or more.', 'participants');
return -Math.expm1(n * Math.log1p(-p));
}
/** Smallest frequency seen with chance P at n: 1 − (1 − P)^(1/n). methods.md §9. */
function smallestFrequencySeen(n, P) {
requireOpenUnit(P, 'Chance of seeing the problem (P)', 'P');
if (!(n >= 1)) fail('Participants must be at least 1.', 'participants');
return -Math.expm1(Math.log1p(-P) / n);
}
// ---------------------------------------------------------------------------
// plan(): the full study (methods.md §1 to §8)
// ---------------------------------------------------------------------------
const MEASURE_TYPES = {
time: { rank: true, binary: false, label: 'Time' },
count: { rank: true, binary: false, label: 'Count' },
binary: { rank: false, binary: true, label: 'Success / failure' },
rating: { rank: false, binary: false, label: 'Rating scale' },
numeric: { rank: false, binary: false, label: 'Other numeric' },
};
// Rounding for display in the trail (methods.md §8).
function round(x, digits) {
const f = Math.pow(10, digits);
return Math.round(x * f) / f;
}
/**
* Omnibus test (step 1 of the analysis with three or more conditions) for a
* result type, paired or not. methods.md §1.4 table.
*/
function omnibusTest(typeKey, paired) {
const t = MEASURE_TYPES[typeKey];
if (!t) fail(`Unknown result type "${typeKey}".`, 'type');
if (t.binary) return paired ? "Cochran's Q test" : 'Chi-square test of independence';
if (t.rank) return paired ? 'Friedman test' : 'Kruskal–Wallis test';
return paired ? 'Repeated-measures ANOVA' : 'One-way ANOVA';
}
// Method for a measure. methods.md §2 table.
function chooseMethod(typeKey, paired) {
const t = MEASURE_TYPES[typeKey];
if (t.binary) return paired ? 'McNemar' : 'Two-proportion test';
if (t.rank) return paired ? 'Wilcoxon signed-rank' : 'Mann–Whitney';
return paired ? 'Paired t-test' : 'Independent t-test';
}
// Plain-language reason for the chosen test (methods.md §2, §8).
function methodReason(typeKey, paired, studyWithin) {
const t = MEASURE_TYPES[typeKey];
const who = paired
? 'each participant gives one value per condition'
: studyWithin
? 'values cannot be linked to a participant, so conditions are compared as independent groups'
: 'each participant experiences one condition';
if (t.binary) {
return paired
? `Success / failure, and ${who}: McNemar's test, without continuity correction, compares the paired outcomes.`