@mark1russell7/layout-contract 0.2.9 → 0.2.11
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/README.md +8 -1
- package/build/index.d.ts +315 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1044 -9
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/corpus.ts +144 -4
- package/src/index.ts +2 -0
- package/src/strategy-space.ts +854 -0
- package/src/verdict.ts +296 -0
package/src/verdict.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// The verdict over families (`docs/VERDICT.md`). highways, packing and the joint bench use the same rule, so they give the same verdict.
|
|
2
|
+
import { compareMeasure, type MeasureSpec } from "./measures.ts";
|
|
3
|
+
import { stepRandom } from "./random.ts";
|
|
4
|
+
import type { LayoutRecord } from "./record.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The largest count of inputs for the exact permutation test. Up to this count, the test enumerates all `2^n` sign sets.
|
|
8
|
+
* At 16 inputs, that is 65 536 sign sets, approximately 3 times the default draws. Above it, the cost doubles with each input, so the test uses seeded draws.
|
|
9
|
+
*/
|
|
10
|
+
export const VERDICT_EXACT_MAX = 16;
|
|
11
|
+
|
|
12
|
+
/** The largest value of the option `exactMax`. At 20 inputs, the exact test has approximately 1 million sign sets. */
|
|
13
|
+
export const VERDICT_EXACT_CAP = 20;
|
|
14
|
+
|
|
15
|
+
/** The default count of the samples of each bootstrap. It is the parameter `bootstrapRuns` of `docs/PROMOTION.md`. */
|
|
16
|
+
export const VERDICT_RUNS = 10000;
|
|
17
|
+
|
|
18
|
+
/** The default count of the random sign sets of the permutation test above `VERDICT_EXACT_MAX` inputs. */
|
|
19
|
+
export const VERDICT_DRAWS = 20000;
|
|
20
|
+
|
|
21
|
+
/** The default seed of the bootstraps and of the random sign sets. */
|
|
22
|
+
export const VERDICT_SEED = 1;
|
|
23
|
+
|
|
24
|
+
/** The fields of a record that the verdict reads. */
|
|
25
|
+
export type VerdictRecord = Pick<LayoutRecord, "strategy" | "input" | "seed" | "measures"> & { readonly skipped?: string };
|
|
26
|
+
|
|
27
|
+
/** One side of a verdict: a strategy id, or a test that selects the records of the side. */
|
|
28
|
+
export type VerdictSide<R extends VerdictRecord = VerdictRecord> = string | ((record: R) => boolean);
|
|
29
|
+
|
|
30
|
+
/** The arguments of `verdict`. `tie` and `level` have no default. */
|
|
31
|
+
export interface VerdictOptions<R extends VerdictRecord = VerdictRecord> {
|
|
32
|
+
/** The unit of the verdict. The family is the only unit. */
|
|
33
|
+
readonly by: "family";
|
|
34
|
+
/** The hook that gives the family of a record, for example `familyOf` of the cell of its input. */
|
|
35
|
+
readonly family: (record: R) => string;
|
|
36
|
+
/** The measure. Its direction tells which side is better. */
|
|
37
|
+
readonly measure: Pick<MeasureSpec, "id" | "direction">;
|
|
38
|
+
/** The baseline side. */
|
|
39
|
+
readonly a: VerdictSide<R>;
|
|
40
|
+
/** The candidate side. A positive difference tells that `b` is better than `a`. */
|
|
41
|
+
readonly b: VerdictSide<R>;
|
|
42
|
+
/**
|
|
43
|
+
* The minimum effect of the sign of a family, as `minEffect` of `MeasureSpec`. A smaller change of the family mean is a tie.
|
|
44
|
+
* `compareMeasure` compares the two family means, so the smallest tie is one grid step of `RECORD_EPS`.
|
|
45
|
+
*/
|
|
46
|
+
readonly tie: number | { readonly rel: number };
|
|
47
|
+
/** The significance level, from 0 to 1. The permutation p must be below it. The intervals have the level `1 - level`. */
|
|
48
|
+
readonly level: number;
|
|
49
|
+
/** The count of the samples of each bootstrap. The default is `VERDICT_RUNS`. */
|
|
50
|
+
readonly runs?: number;
|
|
51
|
+
/** The count of the random sign sets above the exact limit. The default is `VERDICT_DRAWS`. */
|
|
52
|
+
readonly draws?: number;
|
|
53
|
+
/** The seed of the bootstraps and of the random sign sets. The default is `VERDICT_SEED`. */
|
|
54
|
+
readonly seed?: number;
|
|
55
|
+
/** The largest count of inputs for the exact test. The default is `VERDICT_EXACT_MAX`. The value 0 always uses draws. */
|
|
56
|
+
readonly exactMax?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A mean with its bootstrap interval. The interval is clear of 0 when `lo > 0` or `hi < 0`. */
|
|
60
|
+
export interface VerdictInterval {
|
|
61
|
+
readonly mean: number;
|
|
62
|
+
readonly lo: number;
|
|
63
|
+
readonly hi: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The difference of one family: the count of its inputs, the mean of their differences and its sign. */
|
|
67
|
+
export interface FamilyVerdict {
|
|
68
|
+
readonly family: string;
|
|
69
|
+
readonly n: number;
|
|
70
|
+
readonly mean: number;
|
|
71
|
+
/** The value 1 tells that `b` is better, -1 that `b` is worse, and 0 a tie by `tie`. */
|
|
72
|
+
readonly sign: -1 | 0 | 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The stratified paired permutation test. With `exact`, `draws` is the count of all the sign sets. */
|
|
76
|
+
export interface VerdictPermutation {
|
|
77
|
+
readonly p: number;
|
|
78
|
+
readonly exact: boolean;
|
|
79
|
+
readonly draws: number;
|
|
80
|
+
readonly seed: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The two-sided exact sign test of the family signs. It is a report only. Ties do not count. */
|
|
84
|
+
export interface VerdictSignTest {
|
|
85
|
+
readonly wins: number;
|
|
86
|
+
readonly losses: number;
|
|
87
|
+
readonly ties: number;
|
|
88
|
+
readonly p: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The label of a verdict. `better` and `worse` tell the side of `b`. */
|
|
92
|
+
export type VerdictLabel = "better" | "worse" | "depends on the mix" | "no verdict";
|
|
93
|
+
|
|
94
|
+
/** The result of `verdict`. */
|
|
95
|
+
export interface VerdictResult {
|
|
96
|
+
readonly measure: string;
|
|
97
|
+
/** The count of the paired inputs. */
|
|
98
|
+
readonly n: number;
|
|
99
|
+
/** The mean of the family means, with a bootstrap of the seeds inside each family. */
|
|
100
|
+
readonly stratified: VerdictInterval;
|
|
101
|
+
/** The mean of the inputs, with a bootstrap of the inputs. */
|
|
102
|
+
readonly pooled: VerdictInterval;
|
|
103
|
+
readonly permutation: VerdictPermutation;
|
|
104
|
+
/** The families, in sort order. */
|
|
105
|
+
readonly families: readonly FamilyVerdict[];
|
|
106
|
+
readonly signTest: VerdictSignTest;
|
|
107
|
+
/** `true` only when the two means have the same sign, the two intervals are clear of 0 and the permutation p is below `level`. */
|
|
108
|
+
readonly agree: boolean;
|
|
109
|
+
readonly label: VerdictLabel;
|
|
110
|
+
/** The inputs without a pair: one side has no record, a skipped record or no value of the measure. */
|
|
111
|
+
readonly dropped: readonly string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One paired input: its key, its family, the two values and the difference in the direction of the measure. */
|
|
115
|
+
interface Pair {
|
|
116
|
+
readonly key: string;
|
|
117
|
+
readonly family: string;
|
|
118
|
+
readonly a: number;
|
|
119
|
+
readonly b: number;
|
|
120
|
+
readonly d: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** This function gives the mean of a list that is not empty. */
|
|
124
|
+
const meanOf = (values: readonly number[]): number => values.reduce((s, v) => s + v, 0) / values.length;
|
|
125
|
+
|
|
126
|
+
/** This function gives the `q` quantile of sorted values, with linear interpolation. */
|
|
127
|
+
function quantileOf(sorted: readonly number[], q: number): number {
|
|
128
|
+
const at = (sorted.length - 1) * q;
|
|
129
|
+
const lo = Math.floor(at);
|
|
130
|
+
const hi = Math.ceil(at);
|
|
131
|
+
return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** This function gives the interval of the samples of a bootstrap at the level `1 - level`. */
|
|
135
|
+
function intervalOf(mean: number, samples: number[], level: number): VerdictInterval {
|
|
136
|
+
samples.sort((x, y) => x - y);
|
|
137
|
+
return { mean, lo: quantileOf(samples, level / 2), hi: quantileOf(samples, 1 - level / 2) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** This function tells if an interval is clear of 0 on the side of the sign `sign`. */
|
|
141
|
+
const clearOn = (i: VerdictInterval, sign: number): boolean => (sign > 0 ? i.lo > 0 : sign < 0 ? i.hi < 0 : false);
|
|
142
|
+
|
|
143
|
+
/** This function gives the two-sided p of the exact binomial sign test, with `p = 0.5`. No count gives 1. */
|
|
144
|
+
function signP(wins: number, losses: number): number {
|
|
145
|
+
const n = wins + losses;
|
|
146
|
+
if (n === 0) return 1;
|
|
147
|
+
const k = Math.min(wins, losses);
|
|
148
|
+
let tail = 0;
|
|
149
|
+
let c = 1;
|
|
150
|
+
for (let i = 0; i <= k; i++) {
|
|
151
|
+
tail += c;
|
|
152
|
+
c = (c * (n - i)) / (i + 1);
|
|
153
|
+
}
|
|
154
|
+
return Math.min(1, (2 * tail) / 2 ** n);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** This function tells if a value is a positive whole number. */
|
|
158
|
+
const isCount = (v: number): boolean => Number.isInteger(v) && v > 0;
|
|
159
|
+
|
|
160
|
+
/** This function gives the test of one side. */
|
|
161
|
+
function sideTest<R extends VerdictRecord>(side: VerdictSide<R>): (record: R) => boolean {
|
|
162
|
+
return typeof side === "string" ? (r) => r.strategy === side : side;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** This function pairs the records of `a` and `b` by input and seed, and gives the pairs in the order of family and key. */
|
|
166
|
+
function pairsOf<R extends VerdictRecord>(records: Iterable<R>, options: VerdictOptions<R>): { pairs: Pair[]; dropped: string[] } {
|
|
167
|
+
const isA = sideTest(options.a);
|
|
168
|
+
const isB = sideTest(options.b);
|
|
169
|
+
const sides = new Map<string, { a?: R; b?: R }>();
|
|
170
|
+
for (const r of records) {
|
|
171
|
+
const inA = isA(r);
|
|
172
|
+
const inB = isB(r);
|
|
173
|
+
if (inA && inB) throw new RangeError(`verdict: the record of the input "${r.input}" is in the two sides`);
|
|
174
|
+
if (!inA && !inB) continue;
|
|
175
|
+
const key = `${r.input}#${r.seed}`;
|
|
176
|
+
const entry = sides.get(key) ?? {};
|
|
177
|
+
const side = inA ? "a" : "b";
|
|
178
|
+
if (entry[side] !== undefined) throw new RangeError(`verdict: the side ${side} has two records of the input "${r.input}" and the seed ${r.seed}`);
|
|
179
|
+
entry[side] = r;
|
|
180
|
+
sides.set(key, entry);
|
|
181
|
+
}
|
|
182
|
+
const pairs: Pair[] = [];
|
|
183
|
+
const dropped: string[] = [];
|
|
184
|
+
const id = options.measure.id;
|
|
185
|
+
for (const [key, { a, b }] of sides) {
|
|
186
|
+
const va = a?.skipped === undefined ? a?.measures[id] : undefined;
|
|
187
|
+
const vb = b?.skipped === undefined ? b?.measures[id] : undefined;
|
|
188
|
+
if (a === undefined || b === undefined || va === undefined || vb === undefined) {
|
|
189
|
+
dropped.push(key);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!Number.isFinite(va) || !Number.isFinite(vb)) throw new RangeError(`verdict: the measure ${id} of the input "${a.input}" is not finite`);
|
|
193
|
+
const family = options.family(a);
|
|
194
|
+
if (options.family(b) !== family) throw new RangeError(`verdict: the two records of the input "${a.input}" have different families`);
|
|
195
|
+
pairs.push({ key, family, a: va, b: vb, d: options.measure.direction === "higher" ? vb - va : va - vb });
|
|
196
|
+
}
|
|
197
|
+
pairs.sort((x, y) => (x.family < y.family ? -1 : x.family > y.family ? 1 : x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
|
|
198
|
+
dropped.sort();
|
|
199
|
+
return { pairs, dropped };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* This function gives the verdict of the candidate `b` against the baseline `a` on one measure, with the family as the unit (`docs/VERDICT.md`).
|
|
204
|
+
*
|
|
205
|
+
* - It pairs the records by input and seed. The difference of a pair is `b - a` for a measure where more is better, else `a - b`.
|
|
206
|
+
* - `stratified` is the mean of the family means. Its bootstrap draws the seeds inside each family. `pooled` is the mean of the inputs.
|
|
207
|
+
* - The permutation test changes the sign of each difference inside its family. Its statistic is the mean of the family means.
|
|
208
|
+
* - The label is `better` or `worse` only when `agree` is `true`. Opposite signs of the two means give `depends on the mix`.
|
|
209
|
+
*
|
|
210
|
+
* It throws a `RangeError` for a missing `tie` or `level`, and for no pair. Two records of one side and input, or a value that is not finite, also cause a `RangeError`.
|
|
211
|
+
*/
|
|
212
|
+
export function verdict<R extends VerdictRecord>(records: Iterable<R>, options: VerdictOptions<R>): VerdictResult {
|
|
213
|
+
const { tie, level, measure } = options;
|
|
214
|
+
if (options.by !== "family") throw new RangeError(`verdict: the unit is "family", not "${String(options.by)}"`);
|
|
215
|
+
if (tie === undefined || (typeof tie === "number" ? !(tie >= 0) : !(tie.rel >= 0))) throw new RangeError("verdict: tie is a required number of 0 or more, or { rel } of 0 or more");
|
|
216
|
+
if (level === undefined || !(level > 0 && level < 1)) throw new RangeError("verdict: level is a required number between 0 and 1");
|
|
217
|
+
const runs = options.runs ?? VERDICT_RUNS;
|
|
218
|
+
const draws = options.draws ?? VERDICT_DRAWS;
|
|
219
|
+
const seed = options.seed ?? VERDICT_SEED;
|
|
220
|
+
const exactMax = options.exactMax ?? VERDICT_EXACT_MAX;
|
|
221
|
+
if (!isCount(runs) || !isCount(draws)) throw new RangeError("verdict: runs and draws are whole numbers above 0");
|
|
222
|
+
if (!Number.isInteger(exactMax) || exactMax < 0 || exactMax > VERDICT_EXACT_CAP) throw new RangeError(`verdict: exactMax is a whole number from 0 to ${VERDICT_EXACT_CAP}`);
|
|
223
|
+
|
|
224
|
+
const { pairs, dropped } = pairsOf(records, options);
|
|
225
|
+
const n = pairs.length;
|
|
226
|
+
if (n === 0) throw new RangeError(`verdict: the records give no pair of the measure ${measure.id}`);
|
|
227
|
+
|
|
228
|
+
const groups = new Map<string, Pair[]>();
|
|
229
|
+
for (const p of pairs) groups.set(p.family, [...(groups.get(p.family) ?? []), p]);
|
|
230
|
+
const lists = [...groups.values()];
|
|
231
|
+
const spec = { direction: measure.direction, minEffect: tie };
|
|
232
|
+
const families: FamilyVerdict[] = lists.map((list) => {
|
|
233
|
+
const verdictOf = compareMeasure(meanOf(list.map((p) => p.b)), meanOf(list.map((p) => p.a)), spec);
|
|
234
|
+
return { family: list[0]!.family, n: list.length, mean: meanOf(list.map((p) => p.d)), sign: verdictOf === "better" ? 1 : verdictOf === "worse" ? -1 : 0 };
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// The stratified bootstrap draws the seeds of each family with replacement. The pooled bootstrap draws all the inputs.
|
|
238
|
+
const stratMean = meanOf(families.map((f) => f.mean));
|
|
239
|
+
const rs = stepRandom(seed, "verdict.stratified");
|
|
240
|
+
const stratSamples: number[] = [];
|
|
241
|
+
for (let run = 0; run < runs; run++) {
|
|
242
|
+
let sum = 0;
|
|
243
|
+
for (const list of lists) {
|
|
244
|
+
let s = 0;
|
|
245
|
+
for (let j = 0; j < list.length; j++) s += list[Math.floor(rs() * list.length)]!.d;
|
|
246
|
+
sum += s / list.length;
|
|
247
|
+
}
|
|
248
|
+
stratSamples.push(sum / lists.length);
|
|
249
|
+
}
|
|
250
|
+
const stratified = intervalOf(stratMean, stratSamples, level);
|
|
251
|
+
|
|
252
|
+
const pooledMean = meanOf(pairs.map((p) => p.d));
|
|
253
|
+
const rp = stepRandom(seed, "verdict.pooled");
|
|
254
|
+
const pooledSamples: number[] = [];
|
|
255
|
+
for (let run = 0; run < runs; run++) {
|
|
256
|
+
let s = 0;
|
|
257
|
+
for (let j = 0; j < n; j++) s += pairs[Math.floor(rp() * n)]!.d;
|
|
258
|
+
pooledSamples.push(s / n);
|
|
259
|
+
}
|
|
260
|
+
const pooled = intervalOf(pooledMean, pooledSamples, level);
|
|
261
|
+
|
|
262
|
+
// The statistic is a weighted sum: the weight of an input is 1 / (the count of families × the count of its family).
|
|
263
|
+
const terms = lists.flatMap((list) => list.map((p) => p.d / (lists.length * list.length)));
|
|
264
|
+
const observed = Math.abs(terms.reduce((s, t) => s + t, 0));
|
|
265
|
+
const slack = 1e-12 * terms.reduce((s, t) => s + Math.abs(t), 0);
|
|
266
|
+
const exact = n <= exactMax;
|
|
267
|
+
let hits = 0;
|
|
268
|
+
let total: number;
|
|
269
|
+
if (exact) {
|
|
270
|
+
total = 2 ** n;
|
|
271
|
+
for (let mask = 0; mask < total; mask++) {
|
|
272
|
+
let t = 0;
|
|
273
|
+
for (let i = 0; i < n; i++) t += mask & (1 << i) ? -terms[i]! : terms[i]!;
|
|
274
|
+
if (Math.abs(t) >= observed - slack) hits++;
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
total = draws;
|
|
278
|
+
const rf = stepRandom(seed, "verdict.permutation");
|
|
279
|
+
for (let k = 0; k < draws; k++) {
|
|
280
|
+
let t = 0;
|
|
281
|
+
for (let i = 0; i < n; i++) t += rf() < 0.5 ? -terms[i]! : terms[i]!;
|
|
282
|
+
if (Math.abs(t) >= observed - slack) hits++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// A drawn p counts the observed sign set as one draw, so it is always above 0.
|
|
286
|
+
const permutation: VerdictPermutation = { p: exact ? hits / total : (hits + 1) / (total + 1), exact, draws: total, seed };
|
|
287
|
+
|
|
288
|
+
const wins = families.filter((f) => f.sign === 1).length;
|
|
289
|
+
const losses = families.filter((f) => f.sign === -1).length;
|
|
290
|
+
const signTest: VerdictSignTest = { wins, losses, ties: families.length - wins - losses, p: signP(wins, losses) };
|
|
291
|
+
|
|
292
|
+
const sign = Math.sign(stratified.mean);
|
|
293
|
+
const agree = sign !== 0 && sign === Math.sign(pooled.mean) && clearOn(stratified, sign) && clearOn(pooled, sign) && permutation.p < level;
|
|
294
|
+
const label: VerdictLabel = agree ? (sign > 0 ? "better" : "worse") : sign * Math.sign(pooled.mean) < 0 ? "depends on the mix" : "no verdict";
|
|
295
|
+
return { measure: measure.id, n, stratified, pooled, permutation, families, signTest, agree, label, dropped };
|
|
296
|
+
}
|