peeksafe 0.1.0 → 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.
package/src/plan.ts CHANGED
@@ -35,9 +35,13 @@
35
35
  import { type BaselineStat, baselineNullRate, type CaseRef } from "./baseline.js";
36
36
  import {
37
37
  PeeksafeError,
38
+ requireCounts,
38
39
  requireErrorRates,
39
40
  requireOpenProbability,
40
41
  requireProbability,
42
+ requireRunCeiling,
43
+ requireTrialGrid,
44
+ requireUniqueCaseIds,
41
45
  } from "./errors.js";
42
46
  import {
43
47
  betaQuantile,
@@ -50,6 +54,7 @@ import {
50
54
  samplesForEvidence,
51
55
  sprtExpectedN,
52
56
  } from "./stats.js";
57
+ import { DEFAULT_STOP_OPTIONS, settleLogE, shouldStop } from "./stop.js";
53
58
 
54
59
  export interface PlanConfig {
55
60
  /** the drop we want to be able to detect, in probability units (0.15 = 15 points) */
@@ -74,14 +79,14 @@ export interface PlanConfig {
74
79
  /** beyond this many runs for one case we stop calling it "affordable" */
75
80
  runsPerCaseCeiling: number;
76
81
  /**
77
- * Which design the *expected* bill is priced for. A paired observation is two
78
- * runs, so this doubles `typicalRuns`.
82
+ * Which design the *expected* bill and the screening pass are priced for. A
83
+ * paired observation is two runs, and a paired case's observation count is
84
+ * the pairs `shouldStopPaired` takes to settle it at its coupling; an
85
+ * unpaired case's is the runs `shouldStop` takes to stop it.
79
86
  *
80
- * `undefined` means "price the design this plan recommends", which is the
81
- * only self-consistent answer: `makePlan` recommends pairing whenever more
82
- * than a quarter of the suite has an unpaired evidence ceiling under the bar,
83
- * and quoting the *unpaired* bill underneath that recommendation would price
84
- * a design nobody runs.
87
+ * `undefined` prices each case at its own `PlanCase.design`, and a case with
88
+ * none at the suite's `recommendedDesign`. Setting it prices every case at
89
+ * that one design.
85
90
  */
86
91
  design?: "unpaired" | "paired";
87
92
 
@@ -96,6 +101,13 @@ export interface PlanConfig {
96
101
  maxTrials: number;
97
102
  batchSize: number;
98
103
  nullQuantile: number;
104
+ /**
105
+ * The `futilityConfidence` you run `shouldStop` and `shouldStopPaired` at.
106
+ * The expected bill counts the runs or pairs a case takes to stop at this
107
+ * level, so a higher one prices more. `undefined` takes the stopping rules'
108
+ * own default.
109
+ */
110
+ futilityConfidence?: number;
99
111
  }
100
112
 
101
113
  export const DEFAULT_PLAN: PlanConfig = {
@@ -197,7 +209,11 @@ export interface PlanTotals {
197
209
  * over-predicts what the gate actually spends by 5 to 8×, and the numbers below
198
210
  * predict it to within about 10% when checked against real runs.
199
211
  */
200
- /** 1 unpaired, 2 paired, what one observation costs in runs */
212
+ /**
213
+ * What one observation of the expected bill costs in runs, averaged over its
214
+ * cases: 1 when every case runs unpaired, 2 when every case is paired, and in
215
+ * between for a suite that mixes the two.
216
+ */
201
217
  runsPerObservation: number;
202
218
  /** expected *runs* (not observations) for a pull request in which nothing moved */
203
219
  typicalRuns: number;
@@ -218,7 +234,13 @@ export interface Plan {
218
234
  pairedOnly: PlanCase[];
219
235
  /** cases whose baseline rate is too low to lose `mde` points at all */
220
236
  impossible: PlanCase[];
221
- /** the design the recommendation names, and the one `expectedCostUsd` prices */
237
+ /**
238
+ * The design the recommendation names: paired when more than a quarter of
239
+ * the suite is `PAIRED_ONLY` or most decidable cases' own `design` is
240
+ * paired. `expectedCostUsd` prices each case at
241
+ * its own `design` and falls back to this one only for a case with none,
242
+ * unless `config.design` fixes one design for the whole suite.
243
+ */
222
244
  recommendedDesign: "unpaired" | "paired";
223
245
  /** the cheapest design that decides every decidable case */
224
246
  recommendation: string;
@@ -286,12 +308,28 @@ function baselineRunsToClear(
286
308
  return hi;
287
309
  }
288
310
 
311
+ function requirePrices(cfg: PlanConfig, where: string): void {
312
+ if (!(cfg.costPerRunUsd >= 0) || !(cfg.msPerRun >= 0)) {
313
+ throw new PeeksafeError(
314
+ "PEEKSAFE_E_CONFIG",
315
+ `${where}: costPerRunUsd and msPerRun must be ≥ 0`,
316
+ {
317
+ detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
318
+ },
319
+ );
320
+ }
321
+ }
322
+
289
323
  export function planCase(
290
324
  c: CaseRef,
291
325
  b: BaselineStat | undefined,
292
326
  cfg: PlanConfig,
293
327
  m: number,
294
328
  ): PlanCase {
329
+ // Exported on its own, so it refuses what makePlan refuses before calling it: unchecked, a -5/60 baseline was IMPOSSIBLE and a price of -1 billed -$152.
330
+ if (b) requireCounts(b.successes, b.trials, `planCase(${c.id}).baseline`);
331
+ requireErrorRates(cfg.alpha, cfg.beta, "planCase");
332
+ requirePrices(cfg, "planCase");
295
333
  const barLogE = Math.log(ebhSoloThreshold(m, cfg.fdr));
296
334
  const trials = b?.trials ?? 0;
297
335
  const successes = b?.successes ?? 0;
@@ -416,7 +454,8 @@ export function planCase(
416
454
  * expected sample number at a supplied "true" rate per case, and clamped into
417
455
  * the gate's own [minTrials, maxTrials] batch grid.
418
456
  *
419
- * This is the number that predicts what the gate will actually spend.
457
+ * `makePlan` no longer bills from it: an unpaired case there is counted in
458
+ * the runs `shouldStop` takes, which this figure misses either way (#105).
420
459
  * `samplesForEvidence`, the one behind `PlanCase.unpaired.runs`, answers a
421
460
  * different question: how many runs it takes to *certify a regression*. A case
422
461
  * that did not regress never gets there; it hits the H₀ wall in a few batches
@@ -432,6 +471,9 @@ export function expectedSequentialSamples(
432
471
  trueRate: (caseId: string, b: BaselineStat) => number = (_id, b) =>
433
472
  b.trials > 0 ? b.successes / b.trials : 0,
434
473
  ): { samples: number; perCase: Map<string, number> } {
474
+ requireTrialGrid(cfg, "expectedSequentialSamples");
475
+ // A repeated id was added to `samples` once per listing but kept once in `perCase`, so the two disagreed.
476
+ requireUniqueCaseIds(cases, "expectedSequentialSamples");
435
477
  const perCase = new Map<string, number>();
436
478
  let total = 0;
437
479
  for (const c of cases) {
@@ -441,21 +483,112 @@ export function expectedSequentialSamples(
441
483
  const p1 = Math.max(0.005, p0 - cfg.mde);
442
484
  const p = Math.max(0, Math.min(1, trueRate(c.id, b)));
443
485
  const raw = sprtExpectedN(p, p0, p1, cfg.alpha, cfg.beta);
444
- const n =
445
- Number.isFinite(raw) && raw > 0
446
- ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(raw)))
447
- : cfg.maxTrials;
448
- // The gate only ever adds whole batches past the minimum, so round the
449
- // prediction onto the same grid the gate samples on.
450
- const batched =
451
- cfg.minTrials + Math.ceil(Math.max(0, n - cfg.minTrials) / cfg.batchSize) * cfg.batchSize;
452
- const capped = Math.min(cfg.maxTrials, batched);
486
+ const capped = onBatchGrid(
487
+ Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : cfg.maxTrials,
488
+ cfg,
489
+ );
453
490
  perCase.set(c.id, capped);
454
491
  total += capped;
455
492
  }
456
493
  return { samples: total, perCase };
457
494
  }
458
495
 
496
+ type TrialGrid = Pick<PlanConfig, "minTrials" | "maxTrials" | "batchSize">;
497
+
498
+ /** Refuses a set `futilityConfidence` outside (0, 1), where the settle bar is 0 or Infinity. */
499
+ export function requireSettleConfidence(
500
+ cfg: Pick<PlanConfig, "futilityConfidence">,
501
+ where: string,
502
+ ): void {
503
+ if (cfg.futilityConfidence !== undefined) {
504
+ requireOpenProbability(cfg.futilityConfidence, "futilityConfidence", where);
505
+ }
506
+ }
507
+
508
+ // The gate only ever adds whole batches past the minimum, so a prediction is rounded onto the grid it samples on.
509
+ function onBatchGrid(n: number, cfg: TrialGrid): number {
510
+ const clamped = Math.min(cfg.maxTrials, Math.max(cfg.minTrials, n));
511
+ const batched =
512
+ cfg.minTrials + Math.ceil(Math.max(0, clamped - cfg.minTrials) / cfg.batchSize) * cfg.batchSize;
513
+ return Math.min(cfg.maxTrials, batched);
514
+ }
515
+
516
+ /**
517
+ * Expected pairs for a paired case that did not move: the first pair count at
518
+ * which `shouldStopPaired`'s settle e-value, on the mean trajectory at coupling
519
+ * `rho` and `cfg.futilityConfidence` (the stopping rule's default when unset),
520
+ * rules out a drop of `mde`, on the same batch grid as
521
+ * `expectedSequentialSamples`, and `maxTrials` when it never does. The
522
+ * unpaired SPRT figure knows nothing of this rule or of the coupling, which
523
+ * sets how often an unmoved pair disagrees.
524
+ */
525
+ export function expectedSettlePairs(
526
+ rate: number,
527
+ rho: number,
528
+ mde: number,
529
+ cfg: TrialGrid & Pick<PlanConfig, "futilityConfidence">,
530
+ ): number {
531
+ // Unmoved arms disagree each way only when uncoupled: (1 - rho) p (1 - p).
532
+ const each = (1 - rho) * rate * (1 - rate);
533
+ const confidence = cfg.futilityConfidence ?? DEFAULT_STOP_OPTIONS.futilityConfidence;
534
+ const target = -Math.log(1 - confidence);
535
+ for (let n = 1; n <= cfg.maxTrials; n++) {
536
+ const counts = { bothPass: n * (1 - 2 * each), worse: n * each, better: n * each, bothFail: 0 };
537
+ if (settleLogE(counts, mde) >= target) return onBatchGrid(n, cfg);
538
+ }
539
+ return cfg.maxTrials;
540
+ }
541
+
542
+ /**
543
+ * Expected runs `shouldStop` takes to stop an unpaired case whose candidate
544
+ * passes at `rate`, checked at `minTrials` and then every `batchSize` runs up
545
+ * to `maxTrials`, in a suite of `suiteSize`. Exact rather than simulated:
546
+ * the decision depends on the candidate only through its counts, so a dynamic
547
+ * program carries the distribution of the success count from one check to the
548
+ * next. Rounded up to a whole run; an expectation is not a grid point.
549
+ */
550
+ function expectedStopRuns(
551
+ baseline: BaselineStat,
552
+ rate: number,
553
+ suiteSize: number,
554
+ cfg: TrialGrid & Pick<PlanConfig, "mde" | "fdr" | "futilityConfidence">,
555
+ ): number {
556
+ const options = {
557
+ suiteSize,
558
+ mde: cfg.mde,
559
+ fdr: cfg.fdr,
560
+ futilityConfidence: cfg.futilityConfidence,
561
+ };
562
+ // live[k]: probability of k successes in n runs with the case still running.
563
+ let live = new Float64Array([1]);
564
+ let n = 0;
565
+ let expected = 0;
566
+ for (let step = cfg.minTrials; ; step = Math.min(cfg.batchSize, cfg.maxTrials - n)) {
567
+ for (let i = 0; i < step; i++) {
568
+ const next = new Float64Array(live.length + 1);
569
+ for (let k = 0; k < live.length; k++) {
570
+ next[k]! += live[k]! * (1 - rate);
571
+ next[k + 1]! += live[k]! * rate;
572
+ }
573
+ live = next;
574
+ }
575
+ n += step;
576
+ let running = 0;
577
+ for (let k = 0; k <= n; k++) {
578
+ if (live[k] === 0) continue;
579
+ if (n >= cfg.maxTrials || shouldStop({ successes: k, trials: n }, baseline, options).stop) {
580
+ expected += live[k]! * n;
581
+ live[k] = 0;
582
+ } else {
583
+ running += live[k]!;
584
+ }
585
+ }
586
+ if (running === 0) break;
587
+ }
588
+ // Float residue must not push an exact whole number up a run.
589
+ return Math.ceil(expected - 1e-9);
590
+ }
591
+
459
592
  /**
460
593
  * Plan a suite. `pilots` holds a pair table per case from a pilot run of the
461
594
  * paired design, ideally with both arms on the same revision; a case with one
@@ -485,20 +618,17 @@ export function makePlan(
485
618
  // reaches it, and under that function's name for it, `q`. Checked here so
486
619
  // every planner entry point refuses it the same way and names it.
487
620
  requireOpenProbability(cfg.nullQuantile, "nullQuantile", "makePlan");
621
+ requireSettleConfidence(cfg, "makePlan");
622
+ requireRunCeiling(cfg.runsPerCaseCeiling, "makePlan");
623
+ requireTrialGrid(cfg, "makePlan");
488
624
  if (cases.length === 0) {
489
625
  throw new PeeksafeError("PEEKSAFE_E_SUITE_EMPTY", "makePlan: no cases to plan for", {
490
626
  hint: "pass at least one case; makePlan prices a suite, not an empty list",
491
627
  });
492
628
  }
493
- if (!(cfg.costPerRunUsd >= 0) || !(cfg.msPerRun >= 0)) {
494
- throw new PeeksafeError(
495
- "PEEKSAFE_E_CONFIG",
496
- "makePlan: costPerRunUsd and msPerRun must be ≥ 0",
497
- {
498
- detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
499
- },
500
- );
501
- }
629
+ // `gate` refuses a repeated id; planned, it was priced twice and counted twice in the suite size the bar is set from.
630
+ requireUniqueCaseIds(cases, "makePlan");
631
+ requirePrices(cfg, "makePlan");
502
632
 
503
633
  const m = cases.length;
504
634
  const planned = cases.map((c) => {
@@ -529,13 +659,18 @@ export function makePlan(
529
659
  );
530
660
 
531
661
  const pairedOnlyCount = planned.filter((p) => p.detectability === "PAIRED_ONLY").length;
532
- // The design the recommendation will name, computed here because the
533
- // *expected* bill has to be priced for it. `pairedOnly > 25%` is the same
534
- // condition the recommendation string uses; they cannot drift apart.
662
+ const pairedDesignCount = decidableAny.filter((p) => p.design === "paired").length;
663
+ // The recommendation string reads these same two flags, so it cannot drift from the design it names.
664
+ const manyPairedOnly = pairedOnlyCount > cases.length * 0.25;
665
+ const mostlyPaired = pairedDesignCount * 2 > decidableAny.length;
535
666
  const recommendedDesign: "unpaired" | "paired" =
536
667
  cfg.design ??
537
- (decidableAny.length > 0 && pairedOnlyCount > cases.length * 0.25 ? "paired" : "unpaired");
538
- const runsPerObservation = recommendedDesign === "paired" ? 2 : 1;
668
+ (decidableAny.length > 0 && (manyPairedOnly || mostlyPaired) ? "paired" : "unpaired");
669
+ // Per case, like `bestRuns`: one suite-wide design billed a mixed suite as if every case ran it.
670
+ const runsPerObs = (p: PlanCase) =>
671
+ (cfg.design ?? p.design ?? recommendedDesign) === "paired" ? 2 : 1;
672
+ // `screenRuns` counts observations, so each case's pass costs its own design's runs.
673
+ const screenPassRuns = planned.reduce((a, p) => a + cfg.screenRuns * runsPerObs(p), 0);
539
674
 
540
675
  // Screening: everything gets `screenRuns`; only the cases the screen ranks as
541
676
  // plausibly moved go on to the sequential test. A clean PR moves nothing, so
@@ -550,21 +685,45 @@ export function makePlan(
550
685
  // which is `pairs * 2`. The expected total below reads `screenRuns` the same
551
686
  // way, and when this one did not, the two screened figures in `totals`
552
687
  // disagreed about the price of the same pass.
553
- const screenedRuns = m * cfg.screenRuns * runsPerObservation + screenSelected * meanBest;
688
+ const screenedRuns = screenPassRuns + screenSelected * meanBest;
554
689
 
555
690
  const fixedNRuns = planned.reduce((a, p) => a + finite(p.fixedNPerArm), 0);
556
691
 
557
692
  // The expected cost of a pull request that changed nothing, which is what a
558
693
  // pull request almost always is, and therefore the number a budget should be
559
694
  // set from.
560
- // `expectedSequentialSamples` counts *observations*. A paired observation is
561
- // two runs, the candidate and the reference revision on the same seed, and
562
- // so is a paired screening run.
563
- const typical = expectedSequentialSamples(cases, baseline, cfg);
564
- const typicalRuns = typical.samples * runsPerObservation;
565
- const meanTypical = typical.perCase.size > 0 ? typical.samples / typical.perCase.size : 0;
695
+ // Both counts are *observations*. A paired observation is two runs, the
696
+ // candidate and the reference revision on the same seed, and so is a paired
697
+ // screening run. Each case is counted under the rule that stops it: a paired
698
+ // one in pairs to settle, an unpaired one in the runs `shouldStop` takes.
699
+ // Wald's SPRT figure, used for both before, runs a different rule, ignores
700
+ // the coupling and the suite size, and mispriced cases either way.
701
+ const stopRuns = new Map<string, number>();
702
+ const observations = (p: PlanCase) => {
703
+ const b = baseline.get(p.caseId);
704
+ if (!b || b.trials === 0) return 0;
705
+ if (runsPerObs(p) === 2) {
706
+ return expectedSettlePairs(p.baseline.rate, p.pairCoupling, cfg.mde, cfg);
707
+ }
708
+ const key = `${b.successes}/${b.trials}`;
709
+ const known = stopRuns.get(key);
710
+ if (known !== undefined) return known;
711
+ const runs = expectedStopRuns(b, b.successes / b.trials, m, cfg);
712
+ stopRuns.set(key, runs);
713
+ return runs;
714
+ };
715
+ const priced = planned.filter((p) => (baseline.get(p.caseId)?.trials ?? 0) > 0).length;
716
+ const typicalObservations = planned.reduce((a, p) => a + observations(p), 0);
717
+ const typicalRuns = planned.reduce((a, p) => a + observations(p) * runsPerObs(p), 0);
718
+ const runsPerObservation =
719
+ typicalObservations > 0
720
+ ? typicalRuns / typicalObservations
721
+ : recommendedDesign === "paired"
722
+ ? 2
723
+ : 1;
724
+ const meanTypical = priced > 0 ? typicalRuns / priced : 0;
566
725
  const typicalScreenedRuns = Math.ceil(
567
- (m * cfg.screenRuns + Math.ceil(m * screenContinueFraction) * meanTypical) * runsPerObservation,
726
+ screenPassRuns + Math.ceil(m * screenContinueFraction) * meanTypical,
568
727
  );
569
728
 
570
729
  const totals: PlanTotals = {
@@ -604,8 +763,12 @@ export function makePlan(
604
763
  decidableAny.length === 0
605
764
  ? `nothing is decidable at ${(cfg.mde * 100).toFixed(0)} points against this baseline, record more baseline runs or raise the MDE`
606
765
  : recommendedDesign === "paired"
607
- ? `pair the runs: ${pairedOnly.length}/${m} cases have an unpaired evidence ceiling under the bar, so no candidate budget can decide them` +
608
- ` (the costs below are priced at two runs an observation, which is what pairing costs)`
766
+ ? (manyPairedOnly
767
+ ? `pair the runs: ${pairedOnly.length}/${m} cases have an unpaired evidence ceiling under the bar, so no candidate budget can decide them`
768
+ : `pair the runs: ${pairedDesignCount}/${decidableAny.length} decidable cases are cheaper paired than against the stored baseline`) +
769
+ (cfg.design === undefined
770
+ ? ` (the costs below price each case at its own design, two runs an observation where it is paired)`
771
+ : ` (the costs below are priced at two runs an observation, which is what pairing costs)`)
609
772
  : totals.screenedCostUsd < totals.bestCostUsd
610
773
  ? `screen first, then run the sequential test on what the screen keeps ` +
611
774
  `($${totals.screenedCostUsd.toFixed(2)} vs $${totals.bestCostUsd.toFixed(2)})`
@@ -703,6 +866,30 @@ export function affordabilityGrid(
703
866
  // straight from `betaQuantile`, which answers 0 or 1 for a quantile outside
704
867
  // (0,1) and about 0 for NaN instead of refusing. `makePlan` refuses it.
705
868
  requireOpenProbability(cfg.nullQuantile, "nullQuantile", "affordabilityGrid");
869
+ // makePlan refuses both; unchecked, an fdr of 2 priced a grid cheaper than one at 0.05.
870
+ requireOpenProbability(cfg.fdr, "fdr", "affordabilityGrid");
871
+ requireSettleConfidence(cfg, "affordabilityGrid");
872
+ requireTrialGrid(cfg, "affordabilityGrid");
873
+ if (!(cfg.costPerRunUsd >= 0)) {
874
+ throw new PeeksafeError(
875
+ "PEEKSAFE_E_CONFIG",
876
+ `affordabilityGrid: costPerRunUsd must be ≥ 0, got ${cfg.costPerRunUsd}`,
877
+ {
878
+ detail: { costPerRunUsd: cfg.costPerRunUsd },
879
+ },
880
+ );
881
+ }
882
+ for (const m of caseCounts) {
883
+ if (!Number.isInteger(m) || m < 1) {
884
+ throw new PeeksafeError(
885
+ "PEEKSAFE_E_CONFIG",
886
+ `affordabilityGrid: every case count must be a positive integer, got ${m}`,
887
+ {
888
+ detail: { cases: m },
889
+ },
890
+ );
891
+ }
892
+ }
706
893
  // Every other planner entry point refuses a case with no baseline: `planCase`
707
894
  // returns IMPOSSIBLE with "no baseline", `evaluatePoint` throws on
708
895
  // `baselineRuns < 1`, and `gate` throws PEEKSAFE_E_BASELINE_MISSING. This one
@@ -752,10 +939,13 @@ export function affordabilityGrid(
752
939
  const p0 = betaQuantile(1 + s, 1 + baselineTrials - s, cfg.nullQuantile);
753
940
  const p1 = Math.max(0.005, p0 - mde);
754
941
  const rawN = sprtExpectedN(baselineRate, p0, p1, cfg.alpha, cfg.beta);
942
+ // A paired cell counts the pairs `shouldStopPaired` takes to settle, as `makePlan` does; the SPRT figure ignores the coupling.
755
943
  const perCaseTypical =
756
- Number.isFinite(rawN) && rawN > 0
757
- ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(rawN)))
758
- : cfg.maxTrials;
944
+ runsPerObs === 2
945
+ ? expectedSettlePairs(baselineRate, cfg.pairCoupling, mde, cfg)
946
+ : Number.isFinite(rawN) && rawN > 0
947
+ ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(rawN)))
948
+ : cfg.maxTrials;
759
949
  // Priced for the same design as the ceiling above (`runsPerObs`), or the
760
950
  // cell quotes an unpaired price for a plan only pairing can execute.
761
951
  const typicalRuns = Math.ceil(
package/src/rand.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * It is not a fine thing to do by accident, which is why the constants are
21
21
  * spelled out here rather than imported.
22
22
  */
23
- import { PeeksafeError } from "./errors.js";
23
+ import { PeeksafeError, requireProbability } from "./errors.js";
24
24
 
25
25
  export interface Rand {
26
26
  (): number;
@@ -75,11 +75,27 @@ export function hash32(s: string): number {
75
75
  }
76
76
 
77
77
  export function makeRand(seed: number | string): Rand {
78
- const s = typeof seed === "string" ? hash32(seed) : seed >>> 0;
79
- const base = lcg(s === 0 ? 1 : s);
78
+ // `>>> 0` folded NaN, Infinity, 1.5 and 2^32 onto 0, and the old 0-to-1 remap put seed 1 there too.
79
+ if (typeof seed === "number" && !(Number.isInteger(seed) && seed >= 0 && seed < 0x100000000)) {
80
+ throw new PeeksafeError(
81
+ "PEEKSAFE_E_STAT_DOMAIN",
82
+ `makeRand: a numeric seed must be an integer in [0, 2^32), got ${seed}`,
83
+ {
84
+ detail: { seed },
85
+ hint: "pass a string to key a stream by name; it is hashed with hash32",
86
+ },
87
+ );
88
+ }
89
+ const s = typeof seed === "string" ? hash32(seed) : seed;
90
+ // Hull-Dobell: an odd increment and a multiplier of 1 mod 4 give a full period from every seed, 0 included.
91
+ const base = lcg(s);
80
92
  const next = (() => mix32(Math.floor(base() * 0x100000000)) / 0x100000000) as Rand;
81
93
 
82
- next.bernoulli = (p: number) => next() < p;
94
+ // `next() < p` is false for every draw at NaN, so a rate that failed to compute simulated a case that never passes.
95
+ next.bernoulli = (p: number) => {
96
+ requireProbability(p, "p", "rand.bernoulli");
97
+ return next() < p;
98
+ };
83
99
 
84
100
  next.int = (n: number) => {
85
101
  if (!Number.isInteger(n) || n < 1) {