@mark1russell7/rigor-bench 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.d.ts CHANGED
@@ -1,38 +1,5 @@
1
+ import { a as checkPairedTime, i as caseRatios, n as CaseRatio, o as firstArm, r as PairedTime, t as Arm } from "./paired.js";
1
2
  import { BootstrapOptions, Comparison, Interval } from "@mark1russell7/rigor-statistics";
2
- /** The two arms of a pair. Arm `a` is the baseline, and arm `b` is the candidate. */
3
- type Arm = "a" | "b";
4
- /** One round of one pair on one case: the times of the two arms in milliseconds, and the arm that ran first. */
5
- interface PairedTime {
6
- /** The name of the case: one input of the bench. */
7
- readonly case: string;
8
- /** The number of the round, a whole number of 0 or more. */
9
- readonly round: number;
10
- readonly first: Arm;
11
- /** The time of the baseline arm, in milliseconds. */
12
- readonly a: number;
13
- /** The time of the candidate arm, in milliseconds. */
14
- readonly b: number;
15
- }
16
- /**
17
- * This function gives the arm that runs first in a round. The order turns each round. A case with an odd `offset`
18
- * starts with arm `b`, so half of the cases start with each arm.
19
- */
20
- export declare function firstArm(round: number, offset?: number): Arm;
21
- /** This function examines one record. A time that is not a finite number above 0 is an error. */
22
- export declare function checkPairedTime(record: PairedTime): PairedTime;
23
- /** The ratio of one case: the candidate time over the baseline time, over the rounds of the case. */
24
- interface CaseRatio {
25
- readonly case: string;
26
- /** The value `exp(median(log(b / a)))` over the rounds. For an odd count of rounds, it is the median of the ratios. */
27
- readonly ratio: number;
28
- readonly rounds: number;
29
- }
30
- /**
31
- * This function gives the ratio of each case, in the order of the first record of each case.
32
- * It examines each record. It refuses a case with 2 records of one round.
33
- * It also refuses a case whose two orders differ in count by more than 1, because that order did not turn each round.
34
- */
35
- export declare function caseRatios(records: readonly PairedTime[]): CaseRatio[];
36
3
  /**
37
4
  * The two kinds of an A/A band.
38
5
  * - The band `case` holds the ratio of one A/A case. It is wide, so it finds only a large change.
@@ -120,10 +87,14 @@ type VerdictRule = "band" | "difference";
120
87
  interface VerdictOptions extends BandOptions {
121
88
  /** The rule of the verdict. The default is `difference`, from the power test of `verdictPower`. */
122
89
  readonly rule?: VerdictRule;
123
- /** The half width on a log scale of the ratios that the rule `difference` calls `same`. The default is 0.05. */
90
+ /**
91
+ * The half width on a log scale of the ratios that the rule `difference` calls `same`. The default is 0.05.
92
+ * An interval in the margin is `same`, also when it is wholly below 1.
93
+ */
124
94
  readonly margin?: number;
125
95
  /**
126
96
  * The noise cap on the A/A spread of the run, on a log scale. Above the cap, the outcome is `cannot decide`.
97
+ * The cap also limits the bias of the A/A arm: an A/A median ratio out of `[exp(-noiseCap), exp(noiseCap)]` gives `cannot decide`.
127
98
  * The default is no cap.
128
99
  */
129
100
  readonly noiseCap?: number;
@@ -135,13 +106,20 @@ interface VerdictOptions extends BandOptions {
135
106
  */
136
107
  readonly pool?: PoolOptions;
137
108
  }
138
- /** The noise cap of a verdict: the A/A spread of the run and the cap. */
109
+ /** The noise cap of a verdict: the A/A spread of the run, the A/A median ratio of the run and the cap. */
139
110
  interface NoiseCheck {
140
111
  /** The A/A spread of the run, or undefined when the A/A arm has no cases. */
141
112
  readonly spread: number | undefined;
142
113
  readonly cap: number;
143
114
  /** This value is `true` when the spread is above the cap, or when the A/A arm has no cases. */
144
115
  readonly noisy: boolean;
116
+ /** The median ratio of the A/A cases of the run, or undefined when the A/A arm has no cases. A value far from 1 shows a bias of the order or of the arm slots. */
117
+ readonly aaMedian: number | undefined;
118
+ /**
119
+ * This value is `true` when the A/A median ratio of the run is out of `[exp(-cap), exp(cap)]`.
120
+ * With a spread level of 0.5 or more, a biased run is also noisy. The check decides alone only below that level.
121
+ */
122
+ readonly biased: boolean;
145
123
  }
146
124
  /** A time verdict, with the facts that it uses. */
147
125
  interface TimeVerdict {
@@ -171,11 +149,14 @@ interface TimeVerdict {
171
149
  }
172
150
  /**
173
151
  * This function gives the time verdict of the candidate arm `b` against the baseline arm `a`.
174
- * - The rule `difference` gives `faster` when the whole interval is below 1, and `slower` when it is above 1.
175
- * It gives `same` when the interval is in `[exp(-margin), exp(margin)]`.
152
+ * - The rule `difference` gives `same` when the interval is in `[exp(-margin), exp(margin)]`. This test comes first, so
153
+ * an interval in the margin and below 1 is `same`, not `faster`.
154
+ * - Out of the margin, the rule `difference` gives `faster` when the whole interval is below 1, and `slower` when it is above 1.
155
+ * With the margin 0, each outcome stays the same as in 0.1.2.
176
156
  * - The rule `band` gives `faster` below the band, `slower` above the band, and `same` in the band.
177
157
  * - Each other case is `cannot decide`, and so is an A/A arm with too few cases.
178
158
  * - With `noiseCap`, an A/A spread of the run above the cap gives `cannot decide`.
159
+ * - With `noiseCap`, an A/A median ratio of the run out of `[exp(-noiseCap), exp(noiseCap)]` also gives `cannot decide`.
179
160
  * - With `pool`, the pooled band replaces the band of the A/A arm of the run. The run still needs `minimum` A/A cases.
180
161
  */
181
162
  export declare function timeVerdict(candidate: readonly PairedTime[], aa: readonly PairedTime[], options?: VerdictOptions): TimeVerdict;
@@ -227,6 +208,62 @@ interface LimitVerdict {
227
208
  * - Each other verdict is `cannot decide`, and so is an A/A arm with too few cases or a candidate with no cases.
228
209
  */
229
210
  export declare function limitVerdict(candidate: readonly PairedTime[], aa: readonly PairedTime[], options: LimitOptions): LimitVerdict;
211
+ /** The records of each case with a turned order, and the count of the records that the balance removed. */
212
+ interface Balanced<T extends PairedTime> {
213
+ readonly records: T[];
214
+ /** The count of the removed records. It is 0 when each case has a turned order. */
215
+ readonly trimmed: number;
216
+ }
217
+ /**
218
+ * This function removes records until the two orders of each case differ in count by 1 or less.
219
+ * It removes the records of the larger order with the highest rounds first. The other records keep their order.
220
+ * A job that stopped early, or a missing job, can leave a case with 2 more rounds of one order. `caseRatios` refuses such a case.
221
+ */
222
+ export declare function balanceOrders<T extends PairedTime>(records: readonly T[]): Balanced<T>;
223
+ /** The records of one job: the candidate pair and the A/A pair. */
224
+ interface JobRecords {
225
+ /** The label of the job, for example the index of its CI job. A record with a field `job` has this label, or `pooledVerdict` refuses it. */
226
+ readonly job: string;
227
+ readonly candidate: readonly PairedTime[];
228
+ readonly aa: readonly PairedTime[];
229
+ }
230
+ /** The verdict of one job alone. */
231
+ interface JobVerdict {
232
+ readonly job: string;
233
+ readonly verdict: TimeVerdict;
234
+ }
235
+ /** The pooled verdict over the jobs, with the verdict of each job alone. */
236
+ interface PooledVerdict {
237
+ /** The outcome of the pooled verdict, or `cannot decide` when a job decides the opposite direction. */
238
+ readonly outcome: TimeOutcome;
239
+ /** The verdict of the merged and balanced records of all jobs. */
240
+ readonly pooled: TimeVerdict;
241
+ /** The verdict of each job alone, with the options of the pooled verdict. */
242
+ readonly jobs: readonly JobVerdict[];
243
+ /** The jobs whose own outcome is the opposite direction of the pooled outcome. */
244
+ readonly opposed: string[];
245
+ /** The count of the records that `balanceOrders` removed from the merged candidate and A/A records. */
246
+ readonly trimmed: number;
247
+ /** The reason of the pooled verdict, and the opposed jobs. */
248
+ readonly reason: string;
249
+ }
250
+ /**
251
+ * This function gives the time verdict over the records of all jobs.
252
+ * - It merges the records of the jobs, and it balances the merged candidate and A/A records with `balanceOrders`.
253
+ * - The verdict of each job alone uses the same options. A job with `cannot decide` or `same` does not change the outcome.
254
+ * - A job that decides the opposite direction of the pooled outcome makes the outcome `cannot decide`.
255
+ * - It refuses two jobs with one label, and two jobs with a record of one case and one round.
256
+ */
257
+ export declare function pooledVerdict(jobs: readonly JobRecords[], options?: VerdictOptions): PooledVerdict;
258
+ /** The pairs of a run: the records of each pair, by the id of the pair. */
259
+ interface RunPairs {
260
+ readonly pairs: Readonly<Record<string, readonly PairedTime[]>>;
261
+ }
262
+ /**
263
+ * This function gives the verdict of each candidate pair of a run against its A/A pair `aaPair`.
264
+ * It balances the records of each pair with `balanceOrders` first. It refuses an `aaPair` that the run does not have.
265
+ */
266
+ export declare function armVerdicts(run: RunPairs, aaPair: string, options?: VerdictOptions): Record<string, TimeVerdict>;
230
267
  /** The options of a power test. */
231
268
  interface PowerOptions {
232
269
  /** The count of the cases of the candidate and of the A/A arm. */
@@ -248,7 +285,11 @@ interface PowerOptions {
248
285
  interface Power {
249
286
  readonly trials: number;
250
287
  readonly outcomes: Readonly<Record<TimeOutcome, number>>;
251
- /** The share of the trials whose outcome is the true one: `faster` for a ratio below 1, `slower` above 1, `same` at 1. */
288
+ /**
289
+ * The share of the trials whose outcome is the true one. For the rule `difference`, a ratio in the margin of the verdict has the true outcome `same`.
290
+ * That margin is the larger of `margin` and the noise of the pooled band. The pool is the same in each trial, so each trial has the same margin.
291
+ * Out of that margin, and for the rule `band`, the true outcome is `faster` below 1, `slower` above 1 and `same` at 1.
292
+ */
252
293
  readonly power: number;
253
294
  }
254
295
  /**
@@ -285,5 +326,5 @@ interface Rounds {
285
326
  * keeps the work.
286
327
  */
287
328
  export declare function timeRounds(arms: Readonly<Record<Arm, () => number>>, options: RoundsOptions): Rounds;
288
- export type { Arm, Band, BandKind, BandOptions, CaseInterval, CaseLimit, CaseRatio, LimitOptions, LimitOutcome, LimitVerdict, NoiseCheck, PairedTime, PoolFacts, PoolOptions, Power, PowerOptions, Rounds, RoundsOptions, TimeOutcome, TimeVerdict, VerdictOptions, VerdictRule };
329
+ export { type Arm, type Balanced, type Band, type BandKind, type BandOptions, type CaseInterval, type CaseLimit, type CaseRatio, type JobRecords, type JobVerdict, type LimitOptions, type LimitOutcome, type LimitVerdict, type NoiseCheck, type PairedTime, type PoolFacts, type PoolOptions, type PooledVerdict, type Power, type PowerOptions, type Rounds, type RoundsOptions, type RunPairs, type TimeOutcome, type TimeVerdict, type VerdictOptions, type VerdictRule, caseRatios, checkPairedTime, firstArm };
289
330
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/paired.ts","../src/verdict.ts","../src/limit.ts","../src/power.ts","../src/rounds.ts"],"mappings":";;KAOY;;UAGK;;WAEN;;WAEA;WACA,OAAO;;WAEP;;WAEA;;;;;;wBAOK,SAAS,eAAe,kBAAa;;wBAOrC,gBAAgB,QAAQ,aAAa;;UAapC;WACN;;WAEA;WACA;;;;;;;wBAQK,WAAW,kBAAkB,eAAe;;;;;;KC7ChD;;UAGK;;WAEN,OAAO;;WAEP;;WAEA;;WAEA,YAAY;;;UAIN;;WAEN;WACA,MAAM;;;;;;WAMN;;WAEA;WACA;;WAEA;;;wBAIK,OAAO,aAAa,cAAc,UAAS,cAAmB;;;;;wBAoB9D,SAAS,aAAa,cAAc;;UAWnC;;WAEN,kBAAkB;;WAElB;;WAEA;;;;;WAKA,OAAO,QAAQ;;;UAIT;;WAEN;;WAEA;;WAEA;WACA;;WAEA;;;;;;;;wBASK,WAAW,MAAM,aAAa,iBAAc;;KAmBhD;;;;;;;KAQA;;UAGK,uBAAuB;;WAE7B,OAAO;;WAEP;;;;;WAKA;;WAEA;;;;;WAKA,OAAO;;;UAID;;WAEN;WACA;;WAEA;;;UAIM;WACN,SAAS;;WAET;WACA,gBAAgB;WAChB,MAAM;;;;;;;WAON,UAAU;;WAEV,MAAM;;;;;WAKN,YAAY;;WAEZ,OAAO;;WAEP,QAAQ;;;;;;;;;;;wBAYH,YAAY,oBAAoB,cAAc,aAAa,cAAc,UAAS,iBAAsB;;UCnMvG,qBAAqB;;WAE3B;WACA;;WAEA;;;;;;wBAOK,cAAc,kBAAkB,cAAc,iBAAe;;KAcjE;;UAGK,qBAAqB;;WAE3B;;WAEA;;;UAIM,kBAAkB;;WAExB;;WAEA;WACA,SAAS;;;UAIH;WACN,SAAS;;WAET;WACA;WACA,gBAAgB;;WAEhB,MAAM;;;;;;;;;wBAUD,aAAa,oBAAoB,cAAc,aAAa,cAAc,SAAS,eAAe;;UC/DjG;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA,UAAU;;;UAIJ;WACN;WACA,UAAU,SAAS,OAAO;;WAE1B;;;;;;wBAOK,aAAa,SAAS,eAAe;;UC7BpC;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;UAIM;WACN,SAAS;;WAET;;WAEA;;;;;;;wBAQK,WAAW,MAAM,SAAS,OAAO,qBAAqB,SAAS,gBAAgB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/verdict.ts","../src/limit.ts","../src/merge.ts","../src/power.ts","../src/rounds.ts"],"mappings":";;;;;;;KAaY;;UAGK;;WAEN,OAAO;;WAEP;;WAEA;;WAEA,YAAY;;;UAIN;;WAEN;WACA,MAAM;;;;;;WAMN;;WAEA;WACA;;WAEA;;;wBAIK,OAAO,aAAa,cAAc,UAAS,cAAmB;;;;;wBAoB9D,SAAS,aAAa,cAAc;;UAWnC;;WAEN,kBAAkB;;WAElB;;WAEA;;;;;WAKA,OAAO,QAAQ;;;UAIT;;WAEN;;WAEA;;WAEA;WACA;;WAEA;;;;;;;;wBASK,WAAW,MAAM,aAAa,iBAAc;;KAmBhD;;;;;;;KAQA;;UAGK,uBAAuB;;WAE7B,OAAO;;;;;WAKP;;;;;;WAMA;;WAEA;;;;;WAKA,OAAO;;;UAID;;WAEN;WACA;;WAEA;;WAEA;;;;;WAKA;;;UAIM;WACN,SAAS;;WAET;WACA,gBAAgB;WAChB,MAAM;;;;;;;WAON,UAAU;;WAEV,MAAM;;;;;WAKN,YAAY;;WAEZ,OAAO;;WAEP,QAAQ;;;;;;;;;;;;;;wBAeH,YAAY,oBAAoB,cAAc,aAAa,cAAc,UAAS,iBAAsB;;UCjNvG,qBAAqB;;WAE3B;WACA;;WAEA;;;;;;wBAOK,cAAc,kBAAkB,cAAc,iBAAe;;KAcjE;;UAGK,qBAAqB;;WAE3B;;WAEA;;;UAIM,kBAAkB;;WAExB;;WAEA;WACA,SAAS;;;UAIH;WACN,SAAS;;WAET;WACA;WACA,gBAAgB;;WAEhB,MAAM;;;;;;;;;wBAUD,aAAa,oBAAoB,cAAc,aAAa,cAAc,SAAS,eAAe;;UChEjG,SAAS,UAAU;WACzB,SAAS;;WAET;;;;;;;wBAQK,cAAc,UAAU,YAAY,kBAAkB,MAAM,SAAS;;UAkBpE;;WAEN;WACA,oBAAoB;WACpB,aAAa;;;UAIP;WACN;WACA,SAAS;;;UAIH;;WAEN,SAAS;;WAET,QAAQ;;WAER,eAAe;;WAEf;;WAEA;;WAEA;;;;;;;;;wBAYK,cAAc,eAAe,cAAc,UAAS,iBAAsB;;UA4CzE;WACN,OAAO,SAAS,wBAAwB;;;;;;wBAOnC,YAAY,KAAK,UAAU,gBAAgB,UAAS,iBAAsB,eAAe;;UCtHxF;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA,UAAU;;;UAIJ;WACN;WACA,UAAU,SAAS,OAAO;;;;;;WAM1B;;;;;;wBAOK,aAAa,SAAS,eAAe;;UCjCpC;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;UAIM;WACN,SAAS;;WAET;;WAEA;;;;;;;wBAQK,WAAW,MAAM,SAAS,OAAO,qBAAqB,SAAS,gBAAgB"}
package/build/index.js CHANGED
@@ -1,56 +1,7 @@
1
+ import { n as checkPairedTime, r as firstArm, t as caseRatios } from "./paired.js";
1
2
  import { bootstrapMedian, bootstrapTwo, compareRatios, median, medianInterval, quantile } from "@mark1russell7/rigor-statistics";
2
3
  import { random } from "@mark1russell7/rigor-generators";
3
4
  import { performance } from "node:perf_hooks";
4
- // src/paired.ts
5
- /**
6
- * The paired-time record of a bench. Each round runs the two arms of a pair on one case, one after the other, in a
7
- * fresh process. The order turns each round, so a warm cache or a slow start does not always help the same arm.
8
- */
9
- /**
10
- * This function gives the arm that runs first in a round. The order turns each round. A case with an odd `offset`
11
- * starts with arm `b`, so half of the cases start with each arm.
12
- */
13
- function firstArm(round, offset = 0) {
14
- if (!Number.isSafeInteger(round) || round < 0) throw new RangeError(`firstArm: the round is a whole number of 0 or more, not ${round}.`);
15
- if (!Number.isSafeInteger(offset)) throw new RangeError(`firstArm: the offset is a whole number, not ${offset}.`);
16
- return ((round + offset) % 2 + 2) % 2 === 0 ? "a" : "b";
17
- }
18
- /** This function examines one record. A time that is not a finite number above 0 is an error. */
19
- function checkPairedTime(record) {
20
- const where = `the record of the case ${JSON.stringify(record.case)}, round ${record.round}`;
21
- if (typeof record.case !== "string" || record.case === "") throw new RangeError("checkPairedTime: the case is a string that is not empty.");
22
- if (!Number.isSafeInteger(record.round) || record.round < 0) throw new RangeError(`checkPairedTime: in ${where}, the round is a whole number of 0 or more.`);
23
- if (record.first !== "a" && record.first !== "b") throw new RangeError(`checkPairedTime: in ${where}, the first arm is "a" or "b", not ${String(record.first)}.`);
24
- for (const arm of ["a", "b"]) {
25
- const ms = record[arm];
26
- if (!(typeof ms === "number" && Number.isFinite(ms) && ms > 0)) throw new RangeError(`checkPairedTime: in ${where}, the time of arm ${arm} is a finite number above 0, not ${String(ms)}.`);
27
- }
28
- return record;
29
- }
30
- /**
31
- * This function gives the ratio of each case, in the order of the first record of each case.
32
- * It examines each record. It refuses a case with 2 records of one round.
33
- * It also refuses a case whose two orders differ in count by more than 1, because that order did not turn each round.
34
- */
35
- function caseRatios(records) {
36
- const cases = /* @__PURE__ */ new Map();
37
- for (const record of records) {
38
- checkPairedTime(record);
39
- const list = cases.get(record.case) ?? [];
40
- list.push(record);
41
- cases.set(record.case, list);
42
- }
43
- return [...cases].map(([name, list]) => {
44
- if (new Set(list.map((r) => r.round)).size !== list.length) throw new RangeError(`caseRatios: the case ${JSON.stringify(name)} has 2 records of one round.`);
45
- const aFirst = list.filter((r) => r.first === "a").length;
46
- if (Math.abs(2 * aFirst - list.length) > 1) throw new RangeError(`caseRatios: the case ${JSON.stringify(name)} starts with arm a in ${aFirst} of ${list.length} rounds. The order turns each round.`);
47
- return {
48
- case: name,
49
- ratio: Math.exp(median(list.map((r) => Math.log(r.b / r.a)))),
50
- rounds: list.length
51
- };
52
- });
53
- }
54
5
  // src/verdict.ts
55
6
  /**
56
7
  * The time verdict of a candidate against a baseline, with the noise of an A/A arm. An A/A pair runs the same code in
@@ -135,17 +86,20 @@ function poolOf(pool, level) {
135
86
  }
136
87
  /**
137
88
  * This function gives the time verdict of the candidate arm `b` against the baseline arm `a`.
138
- * - The rule `difference` gives `faster` when the whole interval is below 1, and `slower` when it is above 1.
139
- * It gives `same` when the interval is in `[exp(-margin), exp(margin)]`.
89
+ * - The rule `difference` gives `same` when the interval is in `[exp(-margin), exp(margin)]`. This test comes first, so
90
+ * an interval in the margin and below 1 is `same`, not `faster`.
91
+ * - Out of the margin, the rule `difference` gives `faster` when the whole interval is below 1, and `slower` when it is above 1.
92
+ * With the margin 0, each outcome stays the same as in 0.1.2.
140
93
  * - The rule `band` gives `faster` below the band, `slower` above the band, and `same` in the band.
141
94
  * - Each other case is `cannot decide`, and so is an A/A arm with too few cases.
142
95
  * - With `noiseCap`, an A/A spread of the run above the cap gives `cannot decide`.
96
+ * - With `noiseCap`, an A/A median ratio of the run out of `[exp(-noiseCap), exp(noiseCap)]` also gives `cannot decide`.
143
97
  * - With `pool`, the pooled band replaces the band of the A/A arm of the run. The run still needs `minimum` A/A cases.
144
98
  */
145
99
  function timeVerdict(candidate, aa, options = {}) {
146
100
  const level = options.spreadLevel ?? .9;
147
101
  if (options.noiseCap !== void 0 && !(options.noiseCap >= 0 && Number.isFinite(options.noiseCap))) throw new RangeError(`timeVerdict: the noise cap is a finite number of 0 or more, not ${options.noiseCap}.`);
148
- const pool = options.pool === void 0 ? void 0 : poolOf(options.pool, level);
102
+ const pool = poolFor(options);
149
103
  let verdict = ruleVerdict(candidate, aa, options, pool?.band);
150
104
  if (pool !== void 0) verdict = {
151
105
  ...verdict,
@@ -155,10 +109,13 @@ function timeVerdict(candidate, aa, options = {}) {
155
109
  const cap = options.noiseCap;
156
110
  const aaRatios = caseRatios(aa);
157
111
  const spread = aaRatios.length === 0 ? void 0 : spreadOf(aaRatios, level);
112
+ const aaMedian = aaRatios.length === 0 ? void 0 : Math.exp(quantile(aaRatios.map((r) => Math.log(r.ratio)), .5));
158
113
  const noise = {
159
114
  spread,
160
115
  cap,
161
- noisy: spread === void 0 || spread > cap
116
+ noisy: spread === void 0 || spread > cap,
117
+ aaMedian,
118
+ biased: aaMedian !== void 0 && Math.abs(Math.log(aaMedian)) > cap
162
119
  };
163
120
  verdict = {
164
121
  ...verdict,
@@ -171,10 +128,34 @@ function timeVerdict(candidate, aa, options = {}) {
171
128
  outcome: "cannot decide",
172
129
  reason
173
130
  };
131
+ } else if (noise.biased && verdict.band !== void 0) {
132
+ const reason = `The A/A median ratio of the run is ${round$1(aaMedian)}, out of [${round$1(Math.exp(-cap))}, ${round$1(Math.exp(cap))}] of the noise cap ${cap}. Without the cap, the outcome is "${verdict.outcome}".`;
133
+ verdict = {
134
+ ...verdict,
135
+ outcome: "cannot decide",
136
+ reason
137
+ };
174
138
  }
175
139
  }
176
140
  return verdict;
177
141
  }
142
+ /** This function gives the pooled band and its facts of the options, or undefined without the option `pool`. */
143
+ function poolFor(options) {
144
+ return options.pool === void 0 ? void 0 : poolOf(options.pool, options.spreadLevel ?? .9);
145
+ }
146
+ /** This function gives the margin of the rule `difference`: the larger of `margin` and the noise of the pooled band. It refuses a margin below 0. */
147
+ function marginOf(options, pooled) {
148
+ const given = options.margin ?? .05;
149
+ if (!(given >= 0)) throw new RangeError(`timeVerdict: the margin is 0 or more, not ${given}.`);
150
+ return pooled === void 0 ? given : Math.max(given, pooled.noise);
151
+ }
152
+ /**
153
+ * This function gives the margin that the rule `difference` of `timeVerdict` uses with these options. It uses the same code as the verdict.
154
+ * The pooled band comes from the records of the option `pool`, not from the records of the run.
155
+ */
156
+ function verdictMargin(options = {}) {
157
+ return marginOf(options, poolFor(options)?.band);
158
+ }
178
159
  /** This function gives the outcome of the rule. A pooled band replaces the band of the A/A arm, and it widens the margin of the rule `difference`. */
179
160
  function ruleVerdict(candidate, aa, options, pooled) {
180
161
  const rule = options.rule ?? "difference";
@@ -213,9 +194,7 @@ function ruleVerdict(candidate, aa, options, pooled) {
213
194
  })
214
195
  };
215
196
  if (rule === "difference") {
216
- const given = options.margin ?? .05;
217
- if (!(given >= 0)) throw new RangeError(`timeVerdict: the margin is 0 or more, not ${given}.`);
218
- const margin = pooled === void 0 ? given : Math.max(given, pooled.noise);
197
+ const margin = marginOf(options, pooled);
219
198
  const shown = pooled === void 0 ? margin : round$1(margin);
220
199
  const logs = cases.map((c) => Math.log(c.ratio));
221
200
  const aaLogs = aaRatios.map((c) => Math.log(c.ratio));
@@ -227,6 +206,12 @@ function ruleVerdict(candidate, aa, options, pooled) {
227
206
  high: Math.exp(d.high)
228
207
  };
229
208
  const span = `The interval of the candidate median over the A/A median is [${round$1(interval.low)}, ${round$1(interval.high)}].`;
209
+ if (d.low >= -margin && d.high <= margin) return {
210
+ ...facts,
211
+ interval,
212
+ outcome: "same",
213
+ reason: `${span} The interval is in the margin of ${shown}.`
214
+ };
230
215
  if (interval.high < 1) return {
231
216
  ...facts,
232
217
  interval,
@@ -239,12 +224,6 @@ function ruleVerdict(candidate, aa, options, pooled) {
239
224
  outcome: "slower",
240
225
  reason: `${span} The interval is above 1.`
241
226
  };
242
- if (d.low >= -margin && d.high <= margin) return {
243
- ...facts,
244
- interval,
245
- outcome: "same",
246
- reason: `${span} The interval is in the margin of ${shown}.`
247
- };
248
227
  return {
249
228
  ...facts,
250
229
  interval,
@@ -382,6 +361,104 @@ function limitVerdict(candidate, aa, options) {
382
361
  function round(x) {
383
362
  return Math.round(x * 1e4) / 1e4;
384
363
  }
364
+ // src/merge.ts
365
+ /**
366
+ * This function removes records until the two orders of each case differ in count by 1 or less.
367
+ * It removes the records of the larger order with the highest rounds first. The other records keep their order.
368
+ * A job that stopped early, or a missing job, can leave a case with 2 more rounds of one order. `caseRatios` refuses such a case.
369
+ */
370
+ function balanceOrders(records) {
371
+ const byCase = /* @__PURE__ */ new Map();
372
+ records.forEach((record, index) => {
373
+ const orders = byCase.get(record.case) ?? {
374
+ a: [],
375
+ b: []
376
+ };
377
+ orders[record.first === "b" ? "b" : "a"].push(index);
378
+ byCase.set(record.case, orders);
379
+ });
380
+ const drop = /* @__PURE__ */ new Set();
381
+ const highFirst = (x, y) => records[y].round - records[x].round;
382
+ for (const { a, b } of byCase.values()) {
383
+ const [more, fewer] = a.length > b.length ? [a, b] : [b, a];
384
+ const extra = more.length - fewer.length - 1;
385
+ if (extra > 0) for (const index of more.toSorted(highFirst).slice(0, extra)) drop.add(index);
386
+ }
387
+ return {
388
+ records: records.filter((_, index) => !drop.has(index)),
389
+ trimmed: drop.size
390
+ };
391
+ }
392
+ const opposite = {
393
+ faster: "slower",
394
+ slower: "faster"
395
+ };
396
+ /**
397
+ * This function gives the time verdict over the records of all jobs.
398
+ * - It merges the records of the jobs, and it balances the merged candidate and A/A records with `balanceOrders`.
399
+ * - The verdict of each job alone uses the same options. A job with `cannot decide` or `same` does not change the outcome.
400
+ * - A job that decides the opposite direction of the pooled outcome makes the outcome `cannot decide`.
401
+ * - It refuses two jobs with one label, and two jobs with a record of one case and one round.
402
+ */
403
+ function pooledVerdict(jobs, options = {}) {
404
+ const labels = /* @__PURE__ */ new Set();
405
+ for (const { job } of jobs) {
406
+ if (labels.has(job)) throw new RangeError(`pooledVerdict: two jobs have the label ${JSON.stringify(job)}.`);
407
+ labels.add(job);
408
+ }
409
+ for (const { job, candidate: c, aa: a } of jobs) for (const r of [...c, ...a]) if (r.job !== void 0 && r.job !== job) throw new RangeError(`pooledVerdict: a record of the job ${JSON.stringify(job)} has the job ${JSON.stringify(r.job)}.`);
410
+ uniqueRounds(jobs, "candidate");
411
+ uniqueRounds(jobs, "aa");
412
+ const candidate = balanceOrders(jobs.flatMap((j) => j.candidate));
413
+ const aa = balanceOrders(jobs.flatMap((j) => j.aa));
414
+ const pooled = timeVerdict(candidate.records, aa.records, options);
415
+ const each = jobs.map(({ job, candidate: c, aa: a }) => ({
416
+ job,
417
+ verdict: timeVerdict(balanceOrders(c).records, balanceOrders(a).records, options)
418
+ }));
419
+ const against = opposite[pooled.outcome];
420
+ const opposed = against === void 0 ? [] : each.filter((j) => j.verdict.outcome === against).map((j) => j.job);
421
+ const trimmed = candidate.trimmed + aa.trimmed;
422
+ if (opposed.length === 0) return {
423
+ outcome: pooled.outcome,
424
+ pooled,
425
+ jobs: each,
426
+ opposed,
427
+ trimmed,
428
+ reason: pooled.reason
429
+ };
430
+ const names = opposed.map((j) => JSON.stringify(j)).join(", ");
431
+ return {
432
+ outcome: "cannot decide",
433
+ pooled,
434
+ jobs: each,
435
+ opposed,
436
+ trimmed,
437
+ reason: `${pooled.reason} The pooled outcome is "${pooled.outcome}", but the jobs ${names} give "${against}". Thus the outcome is "cannot decide".`
438
+ };
439
+ }
440
+ /** This function refuses two jobs with a record of one case and one round in one pair. */
441
+ function uniqueRounds(jobs, pair) {
442
+ const seen = /* @__PURE__ */ new Map();
443
+ for (const { job, [pair]: records } of jobs) for (const r of records) {
444
+ const key = `${r.case}\u0000${r.round}`;
445
+ const other = seen.get(key);
446
+ if (other !== void 0 && other !== job) throw new RangeError(`pooledVerdict: the jobs ${JSON.stringify(other)} and ${JSON.stringify(job)} both have the round ${r.round} of the case ${JSON.stringify(r.case)} in the ${pair} pair. Set roundOffset of each job to job * rounds.`);
447
+ seen.set(key, job);
448
+ }
449
+ }
450
+ /**
451
+ * This function gives the verdict of each candidate pair of a run against its A/A pair `aaPair`.
452
+ * It balances the records of each pair with `balanceOrders` first. It refuses an `aaPair` that the run does not have.
453
+ */
454
+ function armVerdicts(run, aaPair, options = {}) {
455
+ const aaRecords = run.pairs[aaPair];
456
+ if (aaRecords === void 0) throw new RangeError(`armVerdicts: the run has no pair ${JSON.stringify(aaPair)}.`);
457
+ const aa = balanceOrders(aaRecords).records;
458
+ const out = {};
459
+ for (const [pair, records] of Object.entries(run.pairs)) if (pair !== aaPair) out[pair] = timeVerdict(balanceOrders(records).records, aa, options);
460
+ return out;
461
+ }
385
462
  // src/power.ts
386
463
  /**
387
464
  * The power test of a time verdict. It draws seeded benches with a known speedup and counts the verdicts that find it.
@@ -416,7 +493,7 @@ function verdictPower(options) {
416
493
  }
417
494
  return out;
418
495
  };
419
- const truth = options.ratio < 1 ? "faster" : options.ratio > 1 ? "slower" : "same";
496
+ const truth = trueOutcome(options.ratio, options.verdict);
420
497
  const counts = {
421
498
  faster: 0,
422
499
  slower: 0,
@@ -435,6 +512,11 @@ function verdictPower(options) {
435
512
  power: outcomes[truth]
436
513
  };
437
514
  }
515
+ /** This function gives the true outcome of a ratio. The margin of the rule `difference` comes first, as in `timeVerdict`. */
516
+ function trueOutcome(ratio, verdict = {}) {
517
+ if ((verdict.rule ?? "difference") === "difference" && Math.abs(Math.log(ratio)) <= verdictMargin(verdict)) return "same";
518
+ return ratio < 1 ? "faster" : ratio > 1 ? "slower" : "same";
519
+ }
438
520
  // src/rounds.ts
439
521
  /**
440
522
  * The timed rounds of one case in one process. The two arms first run for a warm-up time. Then the count of calls of
@@ -491,6 +573,6 @@ function timeRounds(arms, options) {
491
573
  sink
492
574
  };
493
575
  }
494
- export { aaBand, aaSpread, caseIntervals, caseRatios, checkPairedTime, firstArm, limitVerdict, pooledBand, timeRounds, timeVerdict, verdictPower };
576
+ export { aaBand, aaSpread, armVerdicts, balanceOrders, caseIntervals, caseRatios, checkPairedTime, firstArm, limitVerdict, pooledBand, pooledVerdict, timeRounds, timeVerdict, verdictPower };
495
577
 
496
578
  //# sourceMappingURL=index.js.map