@mark1russell7/layout-contract 0.2.10 → 0.2.12
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 +5 -0
- package/build/index.d.ts +163 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +347 -5
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/corpus.ts +144 -4
- package/src/index.ts +1 -0
- package/src/verdict.ts +342 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@mark1russell7/layout-contract",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.12",
|
|
5
5
|
"description": "The shared contract of the packing and highways layout engines: the input and record formats, the turn rule, margins, tolerances and hard rules.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"layout",
|
package/src/corpus.ts
CHANGED
|
@@ -19,6 +19,78 @@ export interface DeclaredClaim {
|
|
|
19
19
|
readonly thin: readonly string[];
|
|
20
20
|
/** The count of the inputs for each property id. */
|
|
21
21
|
readonly counts: Readonly<Record<string, number>>;
|
|
22
|
+
/** The key of the family of an input. `familyOf` gives the key of one cell. The family is the unit of a verdict. */
|
|
23
|
+
readonly familyBy?: FamilyBy;
|
|
24
|
+
/** The minimum count of the inputs of each family. Without it, `checkDeclared` does not count the families. */
|
|
25
|
+
readonly familyMin?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The key of the family of a cell (`docs/CORPUS-CHECKS.md`).
|
|
30
|
+
*
|
|
31
|
+
* - The flat form is a list of dimensions. The key is the values of these dimensions of the cell, in order, with `/` between them.
|
|
32
|
+
* - The map form chooses an entry by the field `kind` of the cell, or the entry `"*"`. The key is the kind, then the values of the dimensions of the entry.
|
|
33
|
+
*/
|
|
34
|
+
export type FamilyBy = readonly string[] | { readonly [kind: string]: readonly string[] };
|
|
35
|
+
|
|
36
|
+
/** A cell of an input: one value for each dimension. The map form of `FamilyBy` also reads the field `kind`. */
|
|
37
|
+
export type FamilyCell = Readonly<Record<string, unknown>>;
|
|
38
|
+
|
|
39
|
+
/** The entry of the map form of `FamilyBy` for each kind without its own entry. */
|
|
40
|
+
export const FAMILY_ANY_KIND = "*";
|
|
41
|
+
|
|
42
|
+
/** The separator of the parts of a family key. */
|
|
43
|
+
const FAMILY_SEP = "/";
|
|
44
|
+
|
|
45
|
+
/** The parts of the key of one cell, or the text of the problem. */
|
|
46
|
+
type FamilyParts = { readonly parts: readonly string[] } | { readonly problem: string };
|
|
47
|
+
|
|
48
|
+
/** This function gives the text of a dimension value. A missing value, or a value that is not a string, a number or a boolean, gives `undefined`. */
|
|
49
|
+
function partOf(value: unknown): string | undefined {
|
|
50
|
+
if (typeof value === "string") return value;
|
|
51
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** This function gives the parts of the family key of a cell. A missing dimension, a missing kind or a kind without an entry gives a problem. */
|
|
56
|
+
function familyParts(cell: FamilyCell, familyBy: FamilyBy): FamilyParts {
|
|
57
|
+
let dims: readonly string[];
|
|
58
|
+
let kind: string | undefined;
|
|
59
|
+
const parts: string[] = [];
|
|
60
|
+
if (Array.isArray(familyBy)) {
|
|
61
|
+
dims = familyBy as readonly string[];
|
|
62
|
+
} else {
|
|
63
|
+
const map = familyBy as Readonly<Record<string, readonly string[]>>;
|
|
64
|
+
kind = partOf(cell["kind"]);
|
|
65
|
+
if (kind === undefined) return { problem: "a cell without kind, which the map form of familyBy needs" };
|
|
66
|
+
const entry = Object.hasOwn(map, kind) ? map[kind] : Object.hasOwn(map, FAMILY_ANY_KIND) ? map[FAMILY_ANY_KIND] : undefined;
|
|
67
|
+
if (entry === undefined) return { problem: `the kind "${kind}", which has no entry in familyBy and no entry "${FAMILY_ANY_KIND}"` };
|
|
68
|
+
dims = entry;
|
|
69
|
+
parts.push(kind);
|
|
70
|
+
}
|
|
71
|
+
for (const dim of dims) {
|
|
72
|
+
const part = partOf(cell[dim]);
|
|
73
|
+
if (part === undefined) return { problem: `a cell${kind === undefined ? "" : ` of the kind "${kind}"`} without the dimension "${dim}" of familyBy` };
|
|
74
|
+
parts.push(part);
|
|
75
|
+
}
|
|
76
|
+
return { parts };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* This function gives the family key of a cell. It throws a `RangeError` when the cell does not have a dimension of the key.
|
|
81
|
+
* In the map form, it also throws for a cell without `kind`, and for a kind with no entry and no entry `"*"`.
|
|
82
|
+
* For example, `{ "rect-grid": ["obstacles"], "*": [] }` gives `rect-grid/wall` for a `rect-grid` cell and `gallery` for a `gallery` cell.
|
|
83
|
+
*/
|
|
84
|
+
export function familyOf(cell: FamilyCell, familyBy: FamilyBy): string {
|
|
85
|
+
const found = familyParts(cell, familyBy);
|
|
86
|
+
if ("problem" in found) throw new RangeError(`familyOf: ${found.problem}.`);
|
|
87
|
+
return found.parts.join(FAMILY_SEP);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** This function tells if a value is a `FamilyBy`: a list of dimensions, or a map from a kind to a list of dimensions. */
|
|
91
|
+
function isFamilyBy(value: unknown): value is FamilyBy {
|
|
92
|
+
const isDims = (v: unknown): boolean => Array.isArray(v) && v.every((d) => typeof d === "string");
|
|
93
|
+
return isDims(value) || (isObject(value) && Object.values(value).every(isDims));
|
|
22
94
|
}
|
|
23
95
|
|
|
24
96
|
/** The hook that gives the ids of the properties that one input declares. */
|
|
@@ -54,7 +126,16 @@ export function declaredClaimOf(manifest: object): DeclaredClaim | undefined {
|
|
|
54
126
|
if (!isObject(counts) || !Object.values(counts).every((n) => Number.isInteger(n) && (n as number) >= 0)) {
|
|
55
127
|
throw new Error("The field claims.declared.counts gives a whole count for each property id.");
|
|
56
128
|
}
|
|
57
|
-
|
|
129
|
+
const { familyBy, familyMin } = declared;
|
|
130
|
+
if (familyBy !== undefined && !isFamilyBy(familyBy)) throw new Error("The field claims.declared.familyBy is a list of dimensions, or a map from a kind to a list of dimensions.");
|
|
131
|
+
if (familyMin !== undefined && (!Number.isInteger(familyMin) || (familyMin as number) < 0)) throw new Error("The field claims.declared.familyMin is a whole number of 0 or more.");
|
|
132
|
+
return {
|
|
133
|
+
minCount: minCount as number,
|
|
134
|
+
thin: [...(thin as string[])],
|
|
135
|
+
counts: { ...(counts as Record<string, number>) },
|
|
136
|
+
...(familyBy === undefined ? {} : { familyBy: Array.isArray(familyBy) ? [...familyBy] : { ...(familyBy as Record<string, readonly string[]>) } }),
|
|
137
|
+
...(familyMin === undefined ? {} : { familyMin: familyMin as number }),
|
|
138
|
+
};
|
|
58
139
|
}
|
|
59
140
|
|
|
60
141
|
/**
|
|
@@ -64,9 +145,22 @@ export function declaredClaimOf(manifest: object): DeclaredClaim | undefined {
|
|
|
64
145
|
* - Each id of `ids` has a count in `claim.counts`, and `claim.counts` has no other id. Each count is the count of the inputs.
|
|
65
146
|
* - An id with a count below `minCount` is in `thin`. Each id of `thin` is an id of `ids`.
|
|
66
147
|
* - Each id that `declares` gives is an id of `ids`.
|
|
148
|
+
*
|
|
149
|
+
* With `claim.familyBy`, the hook `cellOf` gives the cell of each input. The default reads the field `cell` of the input.
|
|
150
|
+
*
|
|
151
|
+
* - Each cell has each dimension of its key. In the map form, each cell has a `kind`, and the kind has an entry or the entry `"*"` is there.
|
|
152
|
+
* - Two cells with different dimension values do not give the same key.
|
|
153
|
+
* - With `claim.familyMin`, each family has `familyMin` inputs or more.
|
|
67
154
|
*/
|
|
68
|
-
export function checkDeclared<I>(
|
|
69
|
-
|
|
155
|
+
export function checkDeclared<I>(
|
|
156
|
+
inputs: Iterable<I>,
|
|
157
|
+
declares: Declares<I>,
|
|
158
|
+
claim: DeclaredClaim,
|
|
159
|
+
ids: readonly string[] = Object.keys(claim.counts),
|
|
160
|
+
cellOf: (input: I) => FamilyCell | undefined = cellField,
|
|
161
|
+
): ClaimBreak[] {
|
|
162
|
+
const list = [...inputs];
|
|
163
|
+
const computed = declaredCounts(list, declares, ids);
|
|
70
164
|
const out: string[] = [];
|
|
71
165
|
for (const id of ids) {
|
|
72
166
|
if (!(id in claim.counts)) out.push(`the property "${id}" has no count`);
|
|
@@ -79,7 +173,53 @@ export function checkDeclared<I>(inputs: Iterable<I>, declares: Declares<I>, cla
|
|
|
79
173
|
}
|
|
80
174
|
for (const id of claim.thin) if (!ids.includes(id)) out.push(`"${id}" of thin is not a declared property`);
|
|
81
175
|
for (const id of Object.keys(computed)) if (!ids.includes(id)) out.push(`${computed[id]} inputs declare "${id}", which is not a declared property`);
|
|
82
|
-
|
|
176
|
+
const breaks: ClaimBreak[] = out.map((detail) => ({ claim: "declared", detail, inputs: [] }));
|
|
177
|
+
if (claim.familyBy !== undefined) breaks.push(...checkFamilies(list, claim.familyBy, claim.familyMin, cellOf));
|
|
178
|
+
return breaks;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** This function gives the field `cell` of an input, when it is an object. */
|
|
182
|
+
function cellField(input: unknown): FamilyCell | undefined {
|
|
183
|
+
const cell = isObject(input) ? input["cell"] : undefined;
|
|
184
|
+
return isObject(cell) ? cell : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** This function gives the field `id` of an input, or its place in the list. */
|
|
188
|
+
function inputId(input: unknown, at: number): string {
|
|
189
|
+
const id = isObject(input) ? input["id"] : undefined;
|
|
190
|
+
return typeof id === "string" ? id : `#${at}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** This function gives the breaks of the family keys of the inputs: the problems of each key, the collisions and the thin families. */
|
|
194
|
+
function checkFamilies<I>(inputs: readonly I[], familyBy: FamilyBy, familyMin: number | undefined, cellOf: (input: I) => FamilyCell | undefined): ClaimBreak[] {
|
|
195
|
+
const problems = new Map<string, string[]>();
|
|
196
|
+
const members = new Map<string, { readonly tuples: Map<string, string[]>; readonly ids: string[] }>();
|
|
197
|
+
inputs.forEach((input, at) => {
|
|
198
|
+
const id = inputId(input, at);
|
|
199
|
+
const cell = cellOf(input);
|
|
200
|
+
const found: FamilyParts = cell === undefined ? { problem: "no cell, which familyBy needs" } : familyParts(cell, familyBy);
|
|
201
|
+
if ("problem" in found) {
|
|
202
|
+
problems.set(found.problem, [...(problems.get(found.problem) ?? []), id]);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const key = found.parts.join(FAMILY_SEP);
|
|
206
|
+
const family = members.get(key) ?? { tuples: new Map<string, string[]>(), ids: [] };
|
|
207
|
+
const tuple = JSON.stringify(found.parts);
|
|
208
|
+
family.tuples.set(tuple, [...(family.tuples.get(tuple) ?? []), id]);
|
|
209
|
+
family.ids.push(id);
|
|
210
|
+
members.set(key, family);
|
|
211
|
+
});
|
|
212
|
+
const breaks: ClaimBreak[] = [];
|
|
213
|
+
const add = (detail: string, ids: readonly string[]): void => void breaks.push({ claim: "declared", detail, inputs: [...ids] });
|
|
214
|
+
for (const [problem, ids] of problems) add(`${ids.length} inputs have ${problem}`, ids);
|
|
215
|
+
for (const [key, family] of members) {
|
|
216
|
+
if (family.tuples.size > 1) {
|
|
217
|
+
const forms = [...family.tuples.keys()].join(" and ");
|
|
218
|
+
add(`the family "${key}" comes from different dimension values: ${forms}`, family.ids);
|
|
219
|
+
}
|
|
220
|
+
if (familyMin !== undefined && family.ids.length < familyMin) add(`the family "${key}" has ${family.ids.length} inputs, below familyMin ${familyMin}`, family.ids);
|
|
221
|
+
}
|
|
222
|
+
return breaks;
|
|
83
223
|
}
|
|
84
224
|
|
|
85
225
|
/** The id and the hash of one input of a corpus. */
|
package/src/index.ts
CHANGED
package/src/verdict.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
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
|
+
/**
|
|
95
|
+
* The scope of a verdict. The test keeps the families of the corpus as fixed, so its claim is about these families only.
|
|
96
|
+
* It is not a claim about a new kind of input.
|
|
97
|
+
*/
|
|
98
|
+
export type VerdictScope = "these families";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The breadth of a verdict: the count of the families against its side.
|
|
102
|
+
* The side is the side of the label. For `depends on the mix` and `no verdict`, it is the sign of the stratified mean.
|
|
103
|
+
*/
|
|
104
|
+
export interface VerdictBreadth {
|
|
105
|
+
/** The count of the families with a sign against the side. A stratified mean of exactly 0 has no side, so this count is 0. */
|
|
106
|
+
readonly against: number;
|
|
107
|
+
/** The count of the families. */
|
|
108
|
+
readonly of: number;
|
|
109
|
+
/** The count of the families with the sign 0. */
|
|
110
|
+
readonly ties: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The result of `verdict`. */
|
|
114
|
+
export interface VerdictResult {
|
|
115
|
+
readonly measure: string;
|
|
116
|
+
/** The count of the paired inputs. */
|
|
117
|
+
readonly n: number;
|
|
118
|
+
/** The mean of the family means, with a bootstrap of the seeds inside each family. */
|
|
119
|
+
readonly stratified: VerdictInterval;
|
|
120
|
+
/** The mean of the inputs, with a bootstrap of the inputs. */
|
|
121
|
+
readonly pooled: VerdictInterval;
|
|
122
|
+
readonly permutation: VerdictPermutation;
|
|
123
|
+
/** The families, in sort order. */
|
|
124
|
+
readonly families: readonly FamilyVerdict[];
|
|
125
|
+
readonly signTest: VerdictSignTest;
|
|
126
|
+
/** `true` only when the two means have the same sign, the two intervals are clear of 0 and the permutation p is below `level`. */
|
|
127
|
+
readonly agree: boolean;
|
|
128
|
+
readonly label: VerdictLabel;
|
|
129
|
+
/** The scope of the claim: the families of the corpus, not a new kind of input. */
|
|
130
|
+
readonly scope: VerdictScope;
|
|
131
|
+
/** The count of the families against the side of the verdict, of all the families and of the ties. */
|
|
132
|
+
readonly breadth: VerdictBreadth;
|
|
133
|
+
/** The label, the scope and the breadth in one line, for example `better on these families, worse on 2 of 6 (1 tie)`. */
|
|
134
|
+
readonly summary: string;
|
|
135
|
+
/** The inputs without a pair: one side has no record, a skipped record or no value of the measure. */
|
|
136
|
+
readonly dropped: readonly string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One paired input: its key, its family, the two values and the difference in the direction of the measure. */
|
|
140
|
+
interface Pair {
|
|
141
|
+
readonly key: string;
|
|
142
|
+
readonly family: string;
|
|
143
|
+
readonly a: number;
|
|
144
|
+
readonly b: number;
|
|
145
|
+
readonly d: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** This function gives the mean of a list that is not empty. */
|
|
149
|
+
const meanOf = (values: readonly number[]): number => values.reduce((s, v) => s + v, 0) / values.length;
|
|
150
|
+
|
|
151
|
+
/** This function gives the `q` quantile of sorted values, with linear interpolation. */
|
|
152
|
+
function quantileOf(sorted: readonly number[], q: number): number {
|
|
153
|
+
const at = (sorted.length - 1) * q;
|
|
154
|
+
const lo = Math.floor(at);
|
|
155
|
+
const hi = Math.ceil(at);
|
|
156
|
+
return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** This function gives the interval of the samples of a bootstrap at the level `1 - level`. */
|
|
160
|
+
function intervalOf(mean: number, samples: number[], level: number): VerdictInterval {
|
|
161
|
+
samples.sort((x, y) => x - y);
|
|
162
|
+
return { mean, lo: quantileOf(samples, level / 2), hi: quantileOf(samples, 1 - level / 2) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** This function tells if an interval is clear of 0 on the side of the sign `sign`. */
|
|
166
|
+
const clearOn = (i: VerdictInterval, sign: number): boolean => (sign > 0 ? i.lo > 0 : sign < 0 ? i.hi < 0 : false);
|
|
167
|
+
|
|
168
|
+
/** This function gives the two-sided p of the exact binomial sign test, with `p = 0.5`. No count gives 1. */
|
|
169
|
+
function signP(wins: number, losses: number): number {
|
|
170
|
+
const n = wins + losses;
|
|
171
|
+
if (n === 0) return 1;
|
|
172
|
+
const k = Math.min(wins, losses);
|
|
173
|
+
let tail = 0;
|
|
174
|
+
let c = 1;
|
|
175
|
+
for (let i = 0; i <= k; i++) {
|
|
176
|
+
tail += c;
|
|
177
|
+
c = (c * (n - i)) / (i + 1);
|
|
178
|
+
}
|
|
179
|
+
return Math.min(1, (2 * tail) / 2 ** n);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** This function gives the breadth of a verdict with the side `side`: 1, -1, or 0 for no side. */
|
|
183
|
+
function breadthOf(families: readonly FamilyVerdict[], side: number): VerdictBreadth {
|
|
184
|
+
const ties = families.filter((f) => f.sign === 0).length;
|
|
185
|
+
const against = side === 0 ? 0 : families.filter((f) => f.sign === -side).length;
|
|
186
|
+
return { against, of: families.length, ties };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** This function gives the line of a verdict: its label, its scope and its breadth (`docs/VERDICT.md`). */
|
|
190
|
+
function summaryOf(label: VerdictLabel, side: number, breadth: VerdictBreadth): string {
|
|
191
|
+
const ties = breadth.ties === 0 ? "" : ` (${breadth.ties} ${breadth.ties === 1 ? "tie" : "ties"})`;
|
|
192
|
+
const word = side > 0 ? "better" : "worse";
|
|
193
|
+
const other = side > 0 ? "worse" : "better";
|
|
194
|
+
const count = breadth.against === 0 ? "none" : String(breadth.against);
|
|
195
|
+
if (label === "better" || label === "worse") return `${label} on these families, ${other} on ${count} of ${breadth.of}${ties}`;
|
|
196
|
+
if (side === 0) return `${label}: the family mean is 0, on ${breadth.of} families${ties}`;
|
|
197
|
+
return `${label}: ${word} on the family mean, ${other} on ${count} of ${breadth.of}${ties}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** This function tells if a value is a positive whole number. */
|
|
201
|
+
const isCount = (v: number): boolean => Number.isInteger(v) && v > 0;
|
|
202
|
+
|
|
203
|
+
/** This function gives the test of one side. */
|
|
204
|
+
function sideTest<R extends VerdictRecord>(side: VerdictSide<R>): (record: R) => boolean {
|
|
205
|
+
return typeof side === "string" ? (r) => r.strategy === side : side;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** This function pairs the records of `a` and `b` by input and seed, and gives the pairs in the order of family and key. */
|
|
209
|
+
function pairsOf<R extends VerdictRecord>(records: Iterable<R>, options: VerdictOptions<R>): { pairs: Pair[]; dropped: string[] } {
|
|
210
|
+
const isA = sideTest(options.a);
|
|
211
|
+
const isB = sideTest(options.b);
|
|
212
|
+
const sides = new Map<string, { a?: R; b?: R }>();
|
|
213
|
+
for (const r of records) {
|
|
214
|
+
const inA = isA(r);
|
|
215
|
+
const inB = isB(r);
|
|
216
|
+
if (inA && inB) throw new RangeError(`verdict: the record of the input "${r.input}" is in the two sides`);
|
|
217
|
+
if (!inA && !inB) continue;
|
|
218
|
+
const key = `${r.input}#${r.seed}`;
|
|
219
|
+
const entry = sides.get(key) ?? {};
|
|
220
|
+
const side = inA ? "a" : "b";
|
|
221
|
+
if (entry[side] !== undefined) throw new RangeError(`verdict: the side ${side} has two records of the input "${r.input}" and the seed ${r.seed}`);
|
|
222
|
+
entry[side] = r;
|
|
223
|
+
sides.set(key, entry);
|
|
224
|
+
}
|
|
225
|
+
const pairs: Pair[] = [];
|
|
226
|
+
const dropped: string[] = [];
|
|
227
|
+
const id = options.measure.id;
|
|
228
|
+
for (const [key, { a, b }] of sides) {
|
|
229
|
+
const va = a?.skipped === undefined ? a?.measures[id] : undefined;
|
|
230
|
+
const vb = b?.skipped === undefined ? b?.measures[id] : undefined;
|
|
231
|
+
if (a === undefined || b === undefined || va === undefined || vb === undefined) {
|
|
232
|
+
dropped.push(key);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!Number.isFinite(va) || !Number.isFinite(vb)) throw new RangeError(`verdict: the measure ${id} of the input "${a.input}" is not finite`);
|
|
236
|
+
const family = options.family(a);
|
|
237
|
+
if (options.family(b) !== family) throw new RangeError(`verdict: the two records of the input "${a.input}" have different families`);
|
|
238
|
+
pairs.push({ key, family, a: va, b: vb, d: options.measure.direction === "higher" ? vb - va : va - vb });
|
|
239
|
+
}
|
|
240
|
+
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));
|
|
241
|
+
dropped.sort();
|
|
242
|
+
return { pairs, dropped };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 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`).
|
|
247
|
+
*
|
|
248
|
+
* - 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`.
|
|
249
|
+
* - `stratified` is the mean of the family means. Its bootstrap draws the seeds inside each family. `pooled` is the mean of the inputs.
|
|
250
|
+
* - The permutation test changes the sign of each difference inside its family. Its statistic is the mean of the family means.
|
|
251
|
+
* - The label is `better` or `worse` only when `agree` is `true`. Opposite signs of the two means give `depends on the mix`.
|
|
252
|
+
* - `scope`, `breadth` and `summary` tell that the claim is about these families, and how many families are against it.
|
|
253
|
+
*
|
|
254
|
+
* 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`.
|
|
255
|
+
*/
|
|
256
|
+
export function verdict<R extends VerdictRecord>(records: Iterable<R>, options: VerdictOptions<R>): VerdictResult {
|
|
257
|
+
const { tie, level, measure } = options;
|
|
258
|
+
if (options.by !== "family") throw new RangeError(`verdict: the unit is "family", not "${String(options.by)}"`);
|
|
259
|
+
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");
|
|
260
|
+
if (level === undefined || !(level > 0 && level < 1)) throw new RangeError("verdict: level is a required number between 0 and 1");
|
|
261
|
+
const runs = options.runs ?? VERDICT_RUNS;
|
|
262
|
+
const draws = options.draws ?? VERDICT_DRAWS;
|
|
263
|
+
const seed = options.seed ?? VERDICT_SEED;
|
|
264
|
+
const exactMax = options.exactMax ?? VERDICT_EXACT_MAX;
|
|
265
|
+
if (!isCount(runs) || !isCount(draws)) throw new RangeError("verdict: runs and draws are whole numbers above 0");
|
|
266
|
+
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}`);
|
|
267
|
+
|
|
268
|
+
const { pairs, dropped } = pairsOf(records, options);
|
|
269
|
+
const n = pairs.length;
|
|
270
|
+
if (n === 0) throw new RangeError(`verdict: the records give no pair of the measure ${measure.id}`);
|
|
271
|
+
|
|
272
|
+
const groups = new Map<string, Pair[]>();
|
|
273
|
+
for (const p of pairs) groups.set(p.family, [...(groups.get(p.family) ?? []), p]);
|
|
274
|
+
const lists = [...groups.values()];
|
|
275
|
+
const spec = { direction: measure.direction, minEffect: tie };
|
|
276
|
+
const families: FamilyVerdict[] = lists.map((list) => {
|
|
277
|
+
const verdictOf = compareMeasure(meanOf(list.map((p) => p.b)), meanOf(list.map((p) => p.a)), spec);
|
|
278
|
+
return { family: list[0]!.family, n: list.length, mean: meanOf(list.map((p) => p.d)), sign: verdictOf === "better" ? 1 : verdictOf === "worse" ? -1 : 0 };
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// The stratified bootstrap draws the seeds of each family with replacement. The pooled bootstrap draws all the inputs.
|
|
282
|
+
const stratMean = meanOf(families.map((f) => f.mean));
|
|
283
|
+
const rs = stepRandom(seed, "verdict.stratified");
|
|
284
|
+
const stratSamples: number[] = [];
|
|
285
|
+
for (let run = 0; run < runs; run++) {
|
|
286
|
+
let sum = 0;
|
|
287
|
+
for (const list of lists) {
|
|
288
|
+
let s = 0;
|
|
289
|
+
for (let j = 0; j < list.length; j++) s += list[Math.floor(rs() * list.length)]!.d;
|
|
290
|
+
sum += s / list.length;
|
|
291
|
+
}
|
|
292
|
+
stratSamples.push(sum / lists.length);
|
|
293
|
+
}
|
|
294
|
+
const stratified = intervalOf(stratMean, stratSamples, level);
|
|
295
|
+
|
|
296
|
+
const pooledMean = meanOf(pairs.map((p) => p.d));
|
|
297
|
+
const rp = stepRandom(seed, "verdict.pooled");
|
|
298
|
+
const pooledSamples: number[] = [];
|
|
299
|
+
for (let run = 0; run < runs; run++) {
|
|
300
|
+
let s = 0;
|
|
301
|
+
for (let j = 0; j < n; j++) s += pairs[Math.floor(rp() * n)]!.d;
|
|
302
|
+
pooledSamples.push(s / n);
|
|
303
|
+
}
|
|
304
|
+
const pooled = intervalOf(pooledMean, pooledSamples, level);
|
|
305
|
+
|
|
306
|
+
// The statistic is a weighted sum: the weight of an input is 1 / (the count of families × the count of its family).
|
|
307
|
+
const terms = lists.flatMap((list) => list.map((p) => p.d / (lists.length * list.length)));
|
|
308
|
+
const observed = Math.abs(terms.reduce((s, t) => s + t, 0));
|
|
309
|
+
const slack = 1e-12 * terms.reduce((s, t) => s + Math.abs(t), 0);
|
|
310
|
+
const exact = n <= exactMax;
|
|
311
|
+
let hits = 0;
|
|
312
|
+
let total: number;
|
|
313
|
+
if (exact) {
|
|
314
|
+
total = 2 ** n;
|
|
315
|
+
for (let mask = 0; mask < total; mask++) {
|
|
316
|
+
let t = 0;
|
|
317
|
+
for (let i = 0; i < n; i++) t += mask & (1 << i) ? -terms[i]! : terms[i]!;
|
|
318
|
+
if (Math.abs(t) >= observed - slack) hits++;
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
total = draws;
|
|
322
|
+
const rf = stepRandom(seed, "verdict.permutation");
|
|
323
|
+
for (let k = 0; k < draws; k++) {
|
|
324
|
+
let t = 0;
|
|
325
|
+
for (let i = 0; i < n; i++) t += rf() < 0.5 ? -terms[i]! : terms[i]!;
|
|
326
|
+
if (Math.abs(t) >= observed - slack) hits++;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// A drawn p counts the observed sign set as one draw, so it is always above 0.
|
|
330
|
+
const permutation: VerdictPermutation = { p: exact ? hits / total : (hits + 1) / (total + 1), exact, draws: total, seed };
|
|
331
|
+
|
|
332
|
+
const wins = families.filter((f) => f.sign === 1).length;
|
|
333
|
+
const losses = families.filter((f) => f.sign === -1).length;
|
|
334
|
+
const signTest: VerdictSignTest = { wins, losses, ties: families.length - wins - losses, p: signP(wins, losses) };
|
|
335
|
+
|
|
336
|
+
const sign = Math.sign(stratified.mean);
|
|
337
|
+
const agree = sign !== 0 && sign === Math.sign(pooled.mean) && clearOn(stratified, sign) && clearOn(pooled, sign) && permutation.p < level;
|
|
338
|
+
const label: VerdictLabel = agree ? (sign > 0 ? "better" : "worse") : sign * Math.sign(pooled.mean) < 0 ? "depends on the mix" : "no verdict";
|
|
339
|
+
const breadth = breadthOf(families, sign);
|
|
340
|
+
const summary = summaryOf(label, sign, breadth);
|
|
341
|
+
return { measure: measure.id, n, stratified, pooled, permutation, families, signTest, agree, label, scope: "these families", breadth, summary, dropped };
|
|
342
|
+
}
|