textopt 0.0.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.
Files changed (65) hide show
  1. package/README.md +65 -25
  2. package/dist/bootstrap-search/index.cjs +159 -73
  3. package/dist/bootstrap-search/index.d.cts +32 -10
  4. package/dist/bootstrap-search/index.d.mts +32 -10
  5. package/dist/bootstrap-search/index.mjs +150 -66
  6. package/dist/demos-9v5ts7F3.cjs +244 -0
  7. package/dist/{demos-B0pVQjYC.d.mts → demos-ASsSXYXA.d.mts} +10 -3
  8. package/dist/demos-Brobjfuc.mjs +215 -0
  9. package/dist/{demos-BTuzFNsp.d.cts → demos-ByaLZy-Z.d.cts} +10 -3
  10. package/dist/file-cache.cjs +27 -8
  11. package/dist/file-cache.d.cts +13 -0
  12. package/dist/file-cache.d.mts +13 -0
  13. package/dist/file-cache.mjs +27 -8
  14. package/dist/gepa/index.cjs +128 -80
  15. package/dist/gepa/index.d.cts +15 -7
  16. package/dist/gepa/index.d.mts +15 -7
  17. package/dist/gepa/index.mjs +101 -55
  18. package/dist/index.cjs +157 -30
  19. package/dist/index.d.cts +177 -7
  20. package/dist/index.d.mts +177 -7
  21. package/dist/index.mjs +139 -18
  22. package/dist/{math-COOofUyv.cjs → math-BhlziRPc.cjs} +60 -9
  23. package/dist/math-Dqme4rYz.mjs +123 -0
  24. package/dist/mipro/index.cjs +104 -70
  25. package/dist/mipro/index.d.cts +17 -14
  26. package/dist/mipro/index.d.mts +17 -14
  27. package/dist/mipro/index.mjs +90 -58
  28. package/dist/opro/index.cjs +136 -51
  29. package/dist/opro/index.d.cts +17 -9
  30. package/dist/opro/index.d.mts +17 -9
  31. package/dist/opro/index.mjs +121 -38
  32. package/dist/{optimizer-B7SpRwl7.d.cts → optimizer-4Zv-Zt2t.d.cts} +90 -5
  33. package/dist/{optimizer-DqCoth_w.d.mts → optimizer-Ds5mzYjz.d.mts} +90 -5
  34. package/dist/random-search/index.cjs +99 -49
  35. package/dist/random-search/index.d.cts +15 -13
  36. package/dist/random-search/index.d.mts +15 -13
  37. package/dist/random-search/index.mjs +89 -41
  38. package/dist/{reflection-Cr_upzU0.d.mts → reflection-CMezGu6u.d.mts} +38 -14
  39. package/dist/{reflection-CQToe-5B.d.cts → reflection-D0A7eahD.d.cts} +38 -14
  40. package/dist/reporting-bq007_2z.d.cts +294 -0
  41. package/dist/reporting-bq007_2z.d.mts +294 -0
  42. package/dist/simba/index.cjs +216 -83
  43. package/dist/simba/index.d.cts +53 -13
  44. package/dist/simba/index.d.mts +53 -13
  45. package/dist/simba/index.mjs +206 -75
  46. package/dist/testing.cjs +1 -0
  47. package/dist/testing.d.cts +5 -3
  48. package/dist/testing.d.mts +5 -3
  49. package/dist/testing.mjs +1 -1
  50. package/dist/{evaluation-OZOp6TB7.cjs → warnings-CWRJF-jA.cjs} +228 -5
  51. package/dist/{evaluation-BV0nSZVx.mjs → warnings-OxvDi9kN.mjs} +175 -6
  52. package/docs/adapters.md +169 -0
  53. package/docs/benchmark.md +90 -0
  54. package/docs/data-prep.md +113 -0
  55. package/docs/distillation.md +128 -0
  56. package/docs/evaluation.md +87 -0
  57. package/docs/metric-preflight.md +132 -0
  58. package/docs/optimizers.md +293 -0
  59. package/docs/tuning.md +130 -0
  60. package/package.json +6 -4
  61. package/dist/demos-B9BJiNKz.cjs +0 -143
  62. package/dist/demos-Degx6UmP.mjs +0 -126
  63. package/dist/math-DhrDmpFS.mjs +0 -78
  64. package/dist/types-CWv4IQFF.d.cts +0 -129
  65. package/dist/types-CWv4IQFF.d.mts +0 -129
package/dist/index.mjs CHANGED
@@ -1,10 +1,12 @@
1
- import { a as requireMeasuredMean, c as candidateFingerprint, d as createMemoryCache, i as measuredMean, l as runFingerprint, n as costExhausted, o as createDeadline, p as createBudget, r as createEvaluator, s as assertResumable, t as BudgetExhausted, u as componentNames } from "./evaluation-BV0nSZVx.mjs";
1
+ import { S as createBudget, _ as componentNames, c as BudgetExhausted, d as measuredMean, f as requireMeasuredMean, g as runFingerprint, h as candidateFingerprint, l as costExhausted, m as assertResumable, n as seedScoreWarnings, o as isCandidateAccepted, p as createDeadline, s as isRunFinished, t as resolveValidationSet, u as createEvaluator, y as createMemoryCache } from "./warnings-OxvDi9kN.mjs";
2
2
  import { t as mapWithConcurrency } from "./concurrency-D58PWeSk.mjs";
3
- import { r as signFlipPValue } from "./math-DhrDmpFS.mjs";
4
- import { n as formatDemos, r as parseDemos, t as bootstrapDemos } from "./demos-Degx6UmP.mjs";
3
+ import { i as signFlipPValue, n as holmAdjust } from "./math-Dqme4rYz.mjs";
4
+ import { a as harvestRollouts, n as harvestFewShotExamples, r as parseDemos, t as formatDemos } from "./demos-Brobjfuc.mjs";
5
5
  import { t as parseProposedText } from "./text--v4Ffbus.mjs";
6
6
  //#region src/compare.ts
7
- const EXACT_LIMIT = 16;
7
+ const EXACT_LIMIT = 20;
8
+ /** Well above float subtraction noise (~1e-16), well below a real margin. */
9
+ const DEGENERACY_TOLERANCE = 1e-9;
8
10
  /**
9
11
  * Run several optimizers over the same seeds and report which one actually won.
10
12
  *
@@ -19,7 +21,7 @@ const EXACT_LIMIT = 16;
19
21
  * seed is constructor config and every optimizer here is deterministic given
20
22
  * one — comparing two entrants at a single seed compares two anecdotes. It also
21
23
  * leaves the task where the caller builds it, which is the only place the
22
- * optimizer-specific parts of it (`reflect`, `cache`, `onEvent`) are known.
24
+ * optimizer-specific parts of it (`reflect`, `cache`, `reporters`) are known.
23
25
  */
24
26
  async function compare(args) {
25
27
  const { entrants, seeds, concurrency = 1 } = args;
@@ -42,6 +44,8 @@ async function compare(args) {
42
44
  bestScore: result.bestScore,
43
45
  ...result.testScore === void 0 ? {} : { testScore: result.testScore },
44
46
  metricCalls: result.metricCalls,
47
+ cacheHits: result.cacheHits,
48
+ reflectionCalls: result.reflectionCalls ?? 0,
45
49
  costUsd: result.usage.costUsd,
46
50
  stopReason: result.stopReason
47
51
  };
@@ -52,17 +56,25 @@ async function compare(args) {
52
56
  runs: runs.filter((run) => run.entrant === entrant)
53
57
  }));
54
58
  const winner = summaries.reduce((best, summary) => summary.meanScore > best.meanScore ? summary : best);
59
+ const rawPValues = summaries.map((summary) => summary.entrant === winner.entrant ? void 0 : margin({
60
+ winner: winner.entrant,
61
+ entrant: summary.entrant,
62
+ runs,
63
+ seeds
64
+ }));
65
+ const holmAdjusted = holmAdjustSparse({
66
+ pValues: rawPValues,
67
+ familySize: names.length - 1
68
+ });
55
69
  return {
56
70
  winner: winner.entrant,
57
- summaries: summaries.map((summary) => summary.entrant === winner.entrant ? summary : {
71
+ summaries: summaries.map((summary, index) => ({
58
72
  ...summary,
59
- pValueVsWinner: margin({
60
- winner: winner.entrant,
61
- entrant: summary.entrant,
62
- runs,
63
- seeds
64
- })
65
- }),
73
+ ...rawPValues[index] === void 0 ? {} : {
74
+ pValueVsWinner: rawPValues[index],
75
+ pValueVsWinnerHolm: holmAdjusted[index]
76
+ }
77
+ })),
66
78
  runs
67
79
  };
68
80
  }
@@ -77,13 +89,28 @@ function summarize(args) {
77
89
  minScore: Math.min(...scores),
78
90
  maxScore: Math.max(...scores),
79
91
  meanMetricCalls: mean(runs.map((run) => run.metricCalls)),
80
- meanCostUsd: mean(runs.map((run) => run.costUsd))
92
+ meanCostUsd: mean(runs.map((run) => run.costUsd)),
93
+ meanCacheHits: mean(runs.map((run) => run.cacheHits)),
94
+ meanReflectionCalls: mean(runs.map((run) => run.reflectionCalls)),
95
+ distinctScores: new Set(scores).size
81
96
  };
82
97
  }
83
98
  /**
84
99
  * Paired across seeds rather than pooled: the same seed puts both entrants on
85
100
  * the same sampling order, so the difference at a seed is a comparison and the
86
101
  * spread between seeds is not.
102
+ *
103
+ * Returns `undefined` when every seed produced the exact same nonzero margin.
104
+ * That is not evidence of an n-seed-strong result — it is one realization the
105
+ * seed never varied, and a sign-flip p-value would report a precision from n
106
+ * independent trials that never happened. A margin of exactly zero every seed
107
+ * is not this case: `signFlipPValue` already reports that honestly as 1, no
108
+ * significance claimed either way, which is not a fabricated number.
109
+ *
110
+ * "Exact same" is judged within `DEGENERACY_TOLERANCE`, not `===`: subtracting
111
+ * two scores that are equal in substance can still land a few ULPs apart
112
+ * (0.95 - 0.55 and 0.9 - 0.5 differ at the 16th digit), and treating that as
113
+ * n real trials would be the same fabrication this check exists to prevent.
87
114
  */
88
115
  function margin(args) {
89
116
  const { winner, entrant, runs, seeds } = args;
@@ -98,6 +125,10 @@ function margin(args) {
98
125
  seed
99
126
  });
100
127
  });
128
+ const [first] = differences;
129
+ if (first !== void 0) {
130
+ if (differences.reduce((widest, difference) => Math.max(widest, Math.abs(difference - first)), 0) < DEGENERACY_TOLERANCE && Math.abs(first) > DEGENERACY_TOLERANCE) return;
131
+ }
101
132
  return signFlipPValue({
102
133
  differences,
103
134
  observed: differences.reduce((total, value) => total + value, 0),
@@ -108,6 +139,27 @@ function scoreOf(args) {
108
139
  const { runs, entrant, seed } = args;
109
140
  return runs.find((candidate) => candidate.entrant === entrant && candidate.seed === seed)?.score ?? 0;
110
141
  }
142
+ /**
143
+ * `holmAdjust` over the raw p-values that exist, skipping the slots a
144
+ * withheld comparison left `undefined` — those still count toward
145
+ * `familySize`, they just have nothing of their own to adjust.
146
+ */
147
+ function holmAdjustSparse(args) {
148
+ const { pValues, familySize } = args;
149
+ const present = pValues.map((pValue, index) => ({
150
+ pValue,
151
+ index
152
+ })).filter((entry) => entry.pValue !== void 0);
153
+ const adjusted = holmAdjust({
154
+ pValues: present.map((entry) => entry.pValue),
155
+ familySize
156
+ });
157
+ const result = new Array(pValues.length).fill(void 0);
158
+ present.forEach((entry, rank) => {
159
+ result[entry.index] = adjusted[rank];
160
+ });
161
+ return result;
162
+ }
111
163
  function mean(values) {
112
164
  return values.reduce((total, value) => total + value, 0) / values.length;
113
165
  }
@@ -118,6 +170,51 @@ function standardDeviation(values) {
118
170
  return Math.sqrt(variance);
119
171
  }
120
172
  //#endregion
173
+ //#region src/distillation.ts
174
+ const ROLES = /* @__PURE__ */ new Set([
175
+ "system",
176
+ "user",
177
+ "assistant"
178
+ ]);
179
+ /**
180
+ * Serialize harvested rollouts as JSONL, one training example per line.
181
+ *
182
+ * The chat-messages shape rather than any one vendor's: it is what Axolotl,
183
+ * Together, Fireworks and the Hugging Face trainers all ingest, and the
184
+ * providers that read it outlive the ones that do not.
185
+ *
186
+ * Returns the text rather than writing it. Only `file-cache` touches the
187
+ * filesystem, and a caller uploading this straight to a provider should not
188
+ * have to round-trip it through a file to do so.
189
+ */
190
+ function toTrainingJsonl(args) {
191
+ const { rollouts, render } = args;
192
+ const lines = [];
193
+ for (const [index, rollout] of rollouts.entries()) {
194
+ const example = render({
195
+ rollout,
196
+ index
197
+ });
198
+ if (example === null) continue;
199
+ assertUsable(example, index);
200
+ lines.push(JSON.stringify(example));
201
+ }
202
+ return lines.join("\n");
203
+ }
204
+ /**
205
+ * Checked here rather than left to the provider. A malformed line surfaces as
206
+ * a rejected upload hours later, naming a line number in a file the caller
207
+ * never wrote by hand; naming the rollout at the point it was rendered is the
208
+ * same error while it is still fixable.
209
+ */
210
+ function assertUsable(example, index) {
211
+ if (!Array.isArray(example.messages) || example.messages.length === 0) throw new Error(`rollout ${index} rendered no messages`);
212
+ for (const message of example.messages) {
213
+ if (!ROLES.has(message.role)) throw new Error(`rollout ${index} rendered the unknown role ${message.role}`);
214
+ if (typeof message.content !== "string") throw new Error(`rollout ${index} rendered non-text content`);
215
+ }
216
+ }
217
+ //#endregion
121
218
  //#region src/judge.ts
122
219
  const DEFAULT_SCALE = 5;
123
220
  const SCORE = /<score\s+name="([^"]+)"\s*>\s*([\d.]+)\s*<\/score>/g;
@@ -140,6 +237,11 @@ const FEEDBACK = /<feedback>([\s\S]*?)<\/feedback>/;
140
237
  function createJudge(args) {
141
238
  const { model, criteria, scale = DEFAULT_SCALE, renderInput = stringify, renderOutput = stringify, buildPrompt = buildJudgePrompt } = args;
142
239
  if (criteria.length === 0) throw new Error("createJudge requires at least one criterion");
240
+ if (!Number.isFinite(scale) || scale <= 0) throw new Error(`scale must be a positive number, received ${scale}`);
241
+ assertCriteria({
242
+ criteria,
243
+ scale
244
+ });
143
245
  return async ({ input, output, expected, signal }) => {
144
246
  return readVerdict({
145
247
  response: await model({
@@ -183,6 +285,7 @@ function buildJudgePrompt(args) {
183
285
  `Grade each criterion from 0 to ${scale}, where ${scale} is a perfect answer.`,
184
286
  "",
185
287
  "Then write feedback. It is read by a program that rewrites the system's instructions, not by a person reviewing this output, so say what the instructions should tell the system to do differently. Feedback about this particular answer is of no use to it.",
288
+ ...expected === void 0 ? [] : ["", "Do not restate the expected answer, or any fact drawn from it, in the feedback. The instruction it is rewritten into is reused on inputs whose answers you have not seen: a fact copied out of the expected answer becomes an answer key memorised in the prompt, which raises the score on this input and teaches the system nothing. Name the kind of thing the answer was missing, not the thing itself."],
186
289
  "",
187
290
  "Reply in exactly this format and nothing else:",
188
291
  ...criteria.map((criterion) => `<score name="${criterion.name}">…</score>`),
@@ -201,6 +304,9 @@ function readVerdict(args) {
201
304
  const graded = /* @__PURE__ */ new Map();
202
305
  for (const match of response.matchAll(SCORE)) graded.set(match[1], Number(match[2]));
203
306
  const objectiveScores = {};
307
+ let gated = false;
308
+ let weighted = 0;
309
+ let totalWeight = 0;
204
310
  for (const criterion of criteria) {
205
311
  const grade = graded.get(criterion.name);
206
312
  if (grade === void 0 || Number.isNaN(grade)) return {
@@ -208,15 +314,30 @@ function readVerdict(args) {
208
314
  feedback: `Judge did not grade "${criterion.name}".`,
209
315
  transient: true
210
316
  };
211
- objectiveScores[criterion.name] = clamp(grade / scale);
317
+ const normalized = clamp(grade / scale);
318
+ objectiveScores[criterion.name] = normalized;
319
+ if (criterion.gate !== void 0 && grade < criterion.gate) gated = true;
320
+ const weight = criterion.weight ?? 1;
321
+ weighted += normalized * weight;
322
+ totalWeight += weight;
212
323
  }
213
- const grades = Object.values(objectiveScores);
214
324
  return {
215
- score: grades.reduce((total, grade) => total + grade, 0) / grades.length,
325
+ score: gated ? 0 : weighted / totalWeight,
216
326
  feedback,
217
327
  objectiveScores
218
328
  };
219
329
  }
330
+ function assertCriteria(args) {
331
+ const { criteria, scale } = args;
332
+ let totalWeight = 0;
333
+ for (const { name, weight = 1, gate } of criteria) {
334
+ if (!Number.isFinite(weight) || weight < 0) throw new Error(`weight on criterion "${name}" must be a non-negative number, received ${weight}`);
335
+ totalWeight += weight;
336
+ if (gate !== void 0 && (!Number.isFinite(gate) || gate <= 0)) throw new Error(`gate on criterion "${name}" must be greater than 0, received ${gate}; no grade can fall below 0`);
337
+ if (gate !== void 0 && gate > scale) throw new Error(`gate on criterion "${name}" is ${gate}, above the scale of ${scale}; no grade can reach it`);
338
+ }
339
+ if (totalWeight <= 0) throw new Error("criteria weights must sum to more than 0; at least one criterion has to count towards the score");
340
+ }
220
341
  function stringify(value) {
221
342
  return typeof value === "string" ? value : JSON.stringify(value);
222
343
  }
@@ -242,4 +363,4 @@ function priceUsage(args) {
242
363
  };
243
364
  }
244
365
  //#endregion
245
- export { BudgetExhausted, assertResumable, bootstrapDemos, buildJudgePrompt, candidateFingerprint, compare, componentNames, costExhausted, createBudget, createDeadline, createEvaluator, createJudge, createMemoryCache, formatDemos, mapWithConcurrency, measuredMean, parseDemos, parseProposedText, priceUsage, requireMeasuredMean, runFingerprint };
366
+ export { BudgetExhausted, assertResumable, buildJudgePrompt, candidateFingerprint, compare, componentNames, costExhausted, createBudget, createDeadline, createEvaluator, createJudge, createMemoryCache, formatDemos, harvestFewShotExamples, harvestRollouts, isCandidateAccepted, isRunFinished, mapWithConcurrency, measuredMean, parseDemos, parseProposedText, priceUsage, requireMeasuredMean, resolveValidationSet, runFingerprint, seedScoreWarnings, toTrainingJsonl };
@@ -44,17 +44,62 @@ function signFlipPValue(args) {
44
44
  differences: moved,
45
45
  observed
46
46
  });
47
- const assignments = 2 ** moved.length;
48
- let atLeastAsExtreme = 0;
49
- for (let mask = 0; mask < assignments; mask += 1) {
50
- let total = 0;
51
- for (let index = 0; index < moved.length; index += 1) {
52
- const sign = mask >> index & 1 ? -1 : 1;
53
- total += sign * moved[index];
47
+ const sums = achievableSums(moved);
48
+ return sums.filter((total) => total >= observed).length / sums.length;
49
+ }
50
+ /**
51
+ * Holm-Bonferroni step-down adjustment: the p-value each comparison would need
52
+ * to survive on its own if the whole family were held to one error rate,
53
+ * rather than letting the smallest of several tests read as significant by
54
+ * volume alone. Sorted ascending, each rank is scaled by how many comparisons
55
+ * are still in contention at that rank, and the running maximum keeps a later,
56
+ * less-scaled rank from reporting looser than an earlier one already has.
57
+ *
58
+ * `familySize` may exceed `pValues.length`: a comparison `signFlipPValue`
59
+ * could not test at all (every paired difference identical, so no p reflects
60
+ * a real margin) is still a member of the family being controlled for, and
61
+ * excluding it from the denominator would understate the correction owed to
62
+ * the comparisons that could be tested.
63
+ */
64
+ function holmAdjust(args) {
65
+ const { pValues, familySize } = args;
66
+ const ranked = pValues.map((pValue, index) => ({
67
+ pValue,
68
+ index
69
+ })).sort((a, b) => a.pValue - b.pValue);
70
+ const adjusted = new Array(pValues.length);
71
+ let runningMax = 0;
72
+ ranked.forEach(({ pValue, index }, rank) => {
73
+ runningMax = Math.max(runningMax, Math.min(1, pValue * (familySize - rank)));
74
+ adjusted[index] = runningMax;
75
+ });
76
+ return adjusted;
77
+ }
78
+ /**
79
+ * Every total reachable by flipping some subset of `differences`' signs,
80
+ * built by doubling rather than by scoring each of the 2^n sign masks
81
+ * independently: after `k` differences there are 2^k sums, and folding in
82
+ * difference `k+1` only ever adds or subtracts it from each of them, so the
83
+ * whole enumeration costs O(2^n) instead of the O(2^n * n) a per-mask loop
84
+ * pays for re-summing n terms every time. Measured at n=20, twenty seeds
85
+ * being the bench's ceiling: about 63ms scoring masks one at a time against
86
+ * about 16ms building sums this way — the difference between affording exact
87
+ * enumeration through a full twenty-seed run and falling back to the normal
88
+ * approximation, which is what raising `EXACT_LIMIT` in compare.ts to 20
89
+ * relies on.
90
+ */
91
+ function achievableSums(differences) {
92
+ let sums = [0];
93
+ for (const difference of differences) {
94
+ const next = new Array(sums.length * 2);
95
+ for (let index = 0; index < sums.length; index += 1) {
96
+ const total = sums[index];
97
+ next[index] = total + difference;
98
+ next[index + sums.length] = total - difference;
54
99
  }
55
- if (total >= observed) atLeastAsExtreme += 1;
100
+ sums = next;
56
101
  }
57
- return atLeastAsExtreme / assignments;
102
+ return sums;
58
103
  }
59
104
  /**
60
105
  * The same tail probability from a normal approximation, for batches too large
@@ -81,6 +126,12 @@ Object.defineProperty(exports, "argmax", {
81
126
  return argmax;
82
127
  }
83
128
  });
129
+ Object.defineProperty(exports, "holmAdjust", {
130
+ enumerable: true,
131
+ get: function() {
132
+ return holmAdjust;
133
+ }
134
+ });
84
135
  Object.defineProperty(exports, "mean", {
85
136
  enumerable: true,
86
137
  get: function() {
@@ -0,0 +1,123 @@
1
+ //#region src/math.ts
2
+ function argmax(values) {
3
+ let bestIndex = 0;
4
+ let bestValue = Number.NEGATIVE_INFINITY;
5
+ for (let index = 0; index < values.length; index += 1) {
6
+ const value = values[index];
7
+ if (value > bestValue) {
8
+ bestValue = value;
9
+ bestIndex = index;
10
+ }
11
+ }
12
+ return bestIndex;
13
+ }
14
+ /** Mean over the values that exist; unscored instances are not zeros. */
15
+ function mean(values) {
16
+ let total = 0;
17
+ let count = 0;
18
+ for (const value of values) if (value !== void 0) {
19
+ total += value;
20
+ count += 1;
21
+ }
22
+ return count === 0 ? 0 : total / count;
23
+ }
24
+ function sum(values) {
25
+ let total = 0;
26
+ for (const value of values) total += value;
27
+ return total;
28
+ }
29
+ /**
30
+ * One-sided p-value from a paired sign-flip (permutation) test: how often a
31
+ * total this large arises when each difference is equally likely to have come
32
+ * out the other way. Exact by enumeration for small samples, normal-approximated
33
+ * past `maxExact` where 2^n stops being cheap.
34
+ *
35
+ * Paired and distribution-free, which is what a comparison of two runs over the
36
+ * same instances needs — the scores are not independent draws and are not
37
+ * normal, so a t-test on them is answering a different question.
38
+ */
39
+ function signFlipPValue(args) {
40
+ const { differences, observed, maxExact } = args;
41
+ const moved = differences.filter((difference) => difference !== 0);
42
+ if (moved.length === 0) return 1;
43
+ if (moved.length > maxExact) return normalTailProbability({
44
+ differences: moved,
45
+ observed
46
+ });
47
+ const sums = achievableSums(moved);
48
+ return sums.filter((total) => total >= observed).length / sums.length;
49
+ }
50
+ /**
51
+ * Holm-Bonferroni step-down adjustment: the p-value each comparison would need
52
+ * to survive on its own if the whole family were held to one error rate,
53
+ * rather than letting the smallest of several tests read as significant by
54
+ * volume alone. Sorted ascending, each rank is scaled by how many comparisons
55
+ * are still in contention at that rank, and the running maximum keeps a later,
56
+ * less-scaled rank from reporting looser than an earlier one already has.
57
+ *
58
+ * `familySize` may exceed `pValues.length`: a comparison `signFlipPValue`
59
+ * could not test at all (every paired difference identical, so no p reflects
60
+ * a real margin) is still a member of the family being controlled for, and
61
+ * excluding it from the denominator would understate the correction owed to
62
+ * the comparisons that could be tested.
63
+ */
64
+ function holmAdjust(args) {
65
+ const { pValues, familySize } = args;
66
+ const ranked = pValues.map((pValue, index) => ({
67
+ pValue,
68
+ index
69
+ })).sort((a, b) => a.pValue - b.pValue);
70
+ const adjusted = new Array(pValues.length);
71
+ let runningMax = 0;
72
+ ranked.forEach(({ pValue, index }, rank) => {
73
+ runningMax = Math.max(runningMax, Math.min(1, pValue * (familySize - rank)));
74
+ adjusted[index] = runningMax;
75
+ });
76
+ return adjusted;
77
+ }
78
+ /**
79
+ * Every total reachable by flipping some subset of `differences`' signs,
80
+ * built by doubling rather than by scoring each of the 2^n sign masks
81
+ * independently: after `k` differences there are 2^k sums, and folding in
82
+ * difference `k+1` only ever adds or subtracts it from each of them, so the
83
+ * whole enumeration costs O(2^n) instead of the O(2^n * n) a per-mask loop
84
+ * pays for re-summing n terms every time. Measured at n=20, twenty seeds
85
+ * being the bench's ceiling: about 63ms scoring masks one at a time against
86
+ * about 16ms building sums this way — the difference between affording exact
87
+ * enumeration through a full twenty-seed run and falling back to the normal
88
+ * approximation, which is what raising `EXACT_LIMIT` in compare.ts to 20
89
+ * relies on.
90
+ */
91
+ function achievableSums(differences) {
92
+ let sums = [0];
93
+ for (const difference of differences) {
94
+ const next = new Array(sums.length * 2);
95
+ for (let index = 0; index < sums.length; index += 1) {
96
+ const total = sums[index];
97
+ next[index] = total + difference;
98
+ next[index + sums.length] = total - difference;
99
+ }
100
+ sums = next;
101
+ }
102
+ return sums;
103
+ }
104
+ /**
105
+ * The same tail probability from a normal approximation, for batches too large
106
+ * to enumerate. Under the sign-flip null each difference has mean zero and
107
+ * variance equal to its square, so the total's variance is their sum.
108
+ */
109
+ function normalTailProbability(args) {
110
+ const { differences, observed } = args;
111
+ const variance = differences.reduce((total, difference) => total + difference * difference, 0);
112
+ if (variance === 0) return observed > 0 ? 0 : 1;
113
+ return 1 - standardNormalCdf(observed / Math.sqrt(variance));
114
+ }
115
+ /** Abramowitz and Stegun 7.1.26, which is accurate to about 1e-7. */
116
+ function standardNormalCdf(value) {
117
+ const sign = value < 0 ? -1 : 1;
118
+ const scaled = Math.abs(value) / Math.SQRT2;
119
+ const t = 1 / (1 + .3275911 * scaled);
120
+ return .5 * (1 + sign * (1 - t * (.254829592 + t * (-.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429)))) * Math.exp(-scaled * scaled)));
121
+ }
122
+ //#endregion
123
+ export { sum as a, signFlipPValue as i, holmAdjust as n, mean as r, argmax as t };