@mark1russell7/layout-contract 0.2.7 → 0.2.9
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 +4 -1
- package/build/index.d.ts +102 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +174 -10
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/corpus.ts +197 -0
- package/src/index.ts +1 -0
- package/src/portfolio.ts +32 -11
package/src/corpus.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The checks of the claims of a corpus (`docs/CORPUS-CHECKS.md`). The joint corpus, `packing` and `highways` use the same checks.
|
|
2
|
+
import { canonicalJson, sha256Hex } from "./hash.ts";
|
|
3
|
+
|
|
4
|
+
/** One broken claim of a corpus, with the ids of the inputs that break it. */
|
|
5
|
+
export interface ClaimBreak {
|
|
6
|
+
/** The name of the claim, for example `declared`, `split` or `index`. */
|
|
7
|
+
readonly claim: string;
|
|
8
|
+
/** The text of the break. */
|
|
9
|
+
readonly detail: string;
|
|
10
|
+
/** The ids of the inputs that break the claim. The list is empty when the break has no input. */
|
|
11
|
+
readonly inputs: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The claim `claims.declared` of a manifest: the count of the inputs for each declared property. */
|
|
15
|
+
export interface DeclaredClaim {
|
|
16
|
+
/** A property with fewer inputs than this count is thin. */
|
|
17
|
+
readonly minCount: number;
|
|
18
|
+
/** The ids of the properties with a count below `minCount`. */
|
|
19
|
+
readonly thin: readonly string[];
|
|
20
|
+
/** The count of the inputs for each property id. */
|
|
21
|
+
readonly counts: Readonly<Record<string, number>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The hook that gives the ids of the properties that one input declares. */
|
|
25
|
+
export type Declares<I> = (input: I) => readonly string[];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This function gives the count of the inputs for each property id. Each id of `ids` has a count, also when the count is 0.
|
|
29
|
+
* An id from `declares` that is not in `ids` comes after the ids of `ids`, in the order of the first input that declares it.
|
|
30
|
+
*/
|
|
31
|
+
export function declaredCounts<I>(inputs: Iterable<I>, declares: Declares<I>, ids: readonly string[] = []): Record<string, number> {
|
|
32
|
+
const out: Record<string, number> = Object.fromEntries(ids.map((id) => [id, 0]));
|
|
33
|
+
for (const input of inputs) for (const id of declares(input)) out[id] = (out[id] ?? 0) + 1;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** This function tells if a value is a plain object. */
|
|
38
|
+
const isObject = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* This function reads the field `claims.declared` of a manifest. A manifest without it gives `undefined`.
|
|
42
|
+
* It throws an `Error` for a claim without a whole `minCount`, a list `thin` of ids and a whole count for each id.
|
|
43
|
+
*/
|
|
44
|
+
export function declaredClaimOf(manifest: object): DeclaredClaim | undefined {
|
|
45
|
+
const claims = (manifest as Record<string, unknown>)["claims"];
|
|
46
|
+
if (claims === undefined) return undefined;
|
|
47
|
+
if (!isObject(claims)) throw new Error("The field claims of a corpus file is an object.");
|
|
48
|
+
const declared = claims["declared"];
|
|
49
|
+
if (declared === undefined) return undefined;
|
|
50
|
+
if (!isObject(declared)) throw new Error("The field claims.declared of a corpus file is an object.");
|
|
51
|
+
const { minCount, thin, counts } = declared;
|
|
52
|
+
if (!Number.isInteger(minCount) || (minCount as number) < 0) throw new Error("The field claims.declared.minCount is a whole number of 0 or more.");
|
|
53
|
+
if (!Array.isArray(thin) || !thin.every((id) => typeof id === "string")) throw new Error("The field claims.declared.thin is a list of property ids.");
|
|
54
|
+
if (!isObject(counts) || !Object.values(counts).every((n) => Number.isInteger(n) && (n as number) >= 0)) {
|
|
55
|
+
throw new Error("The field claims.declared.counts gives a whole count for each property id.");
|
|
56
|
+
}
|
|
57
|
+
return { minCount: minCount as number, thin: [...(thin as string[])], counts: { ...(counts as Record<string, number>) } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* This function checks the claim `claims.declared` against the inputs. It gives one break `declared` for each problem. An empty list tells that the claim is true.
|
|
62
|
+
*
|
|
63
|
+
* - `ids` are the property ids. The default is the ids of `claim.counts`.
|
|
64
|
+
* - 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
|
+
* - An id with a count below `minCount` is in `thin`. Each id of `thin` is an id of `ids`.
|
|
66
|
+
* - Each id that `declares` gives is an id of `ids`.
|
|
67
|
+
*/
|
|
68
|
+
export function checkDeclared<I>(inputs: Iterable<I>, declares: Declares<I>, claim: DeclaredClaim, ids: readonly string[] = Object.keys(claim.counts)): ClaimBreak[] {
|
|
69
|
+
const computed = declaredCounts(inputs, declares, ids);
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
for (const id of ids) {
|
|
72
|
+
if (!(id in claim.counts)) out.push(`the property "${id}" has no count`);
|
|
73
|
+
else if (claim.counts[id] !== computed[id]) out.push(`the property "${id}" has the count ${claim.counts[id]}, but the inputs give ${computed[id]}`);
|
|
74
|
+
}
|
|
75
|
+
for (const id of Object.keys(claim.counts)) if (!ids.includes(id)) out.push(`"${id}" is not a declared property`);
|
|
76
|
+
for (const id of ids) {
|
|
77
|
+
const thin = (computed[id] ?? 0) < claim.minCount;
|
|
78
|
+
if (thin && !claim.thin.includes(id)) out.push(`the property "${id}" has ${computed[id]} inputs, below minCount ${claim.minCount}, and it is not in thin`);
|
|
79
|
+
}
|
|
80
|
+
for (const id of claim.thin) if (!ids.includes(id)) out.push(`"${id}" of thin is not a declared property`);
|
|
81
|
+
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
|
+
return out.map((detail) => ({ claim: "declared", detail, inputs: [] }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The id and the hash of one input of a corpus. */
|
|
86
|
+
export interface CorpusEntry {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly hash: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* This function gives the hash of a corpus: the first 16 hex characters of a SHA-256. The text of the SHA-256 is `canonicalJson`
|
|
93
|
+
* of the corpus id and the `[id, hash]` of each input, in the order of the manifest. A change of one input changes the hash.
|
|
94
|
+
*/
|
|
95
|
+
export function corpusHashOf(corpus: string, inputs: readonly CorpusEntry[]): string {
|
|
96
|
+
return sha256Hex(canonicalJson({ corpus, inputs: inputs.map((i) => [i.id, i.hash]) })).slice(0, 16);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The schema of a split file. */
|
|
100
|
+
export const SPLIT_SCHEMA = "corpus-split/1";
|
|
101
|
+
|
|
102
|
+
/** One fold of a split: the seeds that fit a selector and the seeds that score it. */
|
|
103
|
+
export interface SplitFold {
|
|
104
|
+
readonly train: readonly number[];
|
|
105
|
+
readonly heldOut: readonly number[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A split file `corpus-split/1`. Its name is `<corpus id>.<corpus hash>.json`. It is beside the corpus, so the corpus does not change. */
|
|
109
|
+
export interface CorpusSplit {
|
|
110
|
+
readonly schema: typeof SPLIT_SCHEMA;
|
|
111
|
+
readonly corpus: string;
|
|
112
|
+
/** The hash of the corpus: `corpusHashOf` of its manifest. */
|
|
113
|
+
readonly corpusHash: string;
|
|
114
|
+
/** The splits by name. Each split has one fold or more. */
|
|
115
|
+
readonly splits: Readonly<Record<string, { readonly folds: readonly SplitFold[] }>>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The fields of a manifest that the split check reads. */
|
|
119
|
+
export interface SplitCorpus {
|
|
120
|
+
readonly corpus: string;
|
|
121
|
+
readonly specs: readonly { readonly seeds: readonly number[] }[];
|
|
122
|
+
readonly inputs: readonly CorpusEntry[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** This function gives the name of the split file of a corpus. */
|
|
126
|
+
export function splitFileName(corpus: string, hash: string): string {
|
|
127
|
+
return `${corpus}.${hash}.json`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** This function tells if a value is a list of whole numbers. */
|
|
131
|
+
const isSeeds = (value: unknown): value is number[] => Array.isArray(value) && value.every((s) => Number.isInteger(s));
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* This function checks a split file against a manifest. It gives one break `split` for each problem. An empty list tells that the file is good.
|
|
135
|
+
*
|
|
136
|
+
* - The schema is `corpus-split/1`, the corpus is the id of the manifest and `corpusHash` is `corpusHashOf` of the manifest.
|
|
137
|
+
* - Each split has one fold or more. In each fold, `train` and `heldOut` are lists of seeds, not empty and with no common seed.
|
|
138
|
+
* - Each seed is a seed of a spec of the manifest.
|
|
139
|
+
* - With `name`, the file has the split `name`.
|
|
140
|
+
*/
|
|
141
|
+
export function checkSplit(split: unknown, corpus: SplitCorpus, name?: string): ClaimBreak[] {
|
|
142
|
+
const out: string[] = [];
|
|
143
|
+
const done = (): ClaimBreak[] => out.map((detail) => ({ claim: "split", detail, inputs: [] }));
|
|
144
|
+
const f = split as Partial<CorpusSplit> | null;
|
|
145
|
+
if (f === null || typeof f !== "object") {
|
|
146
|
+
out.push("the split file is not an object");
|
|
147
|
+
return done();
|
|
148
|
+
}
|
|
149
|
+
if (f.schema !== SPLIT_SCHEMA) out.push(`the schema is ${String(f.schema)}, not ${SPLIT_SCHEMA}`);
|
|
150
|
+
if (f.corpus !== corpus.corpus) out.push(`the corpus is ${String(f.corpus)}, not ${corpus.corpus}`);
|
|
151
|
+
const hash = corpusHashOf(corpus.corpus, corpus.inputs);
|
|
152
|
+
if (f.corpusHash !== hash) out.push(`the corpusHash is ${String(f.corpusHash)}, but the corpus gives ${hash}`);
|
|
153
|
+
const seeds = new Set(corpus.specs.flatMap((s) => s.seeds));
|
|
154
|
+
if (f.splits === null || typeof f.splits !== "object") {
|
|
155
|
+
out.push("the field splits is not an object");
|
|
156
|
+
return done();
|
|
157
|
+
}
|
|
158
|
+
for (const [key, value] of Object.entries(f.splits)) {
|
|
159
|
+
const folds = (value as { folds?: unknown } | null)?.folds;
|
|
160
|
+
if (!Array.isArray(folds) || folds.length === 0) {
|
|
161
|
+
out.push(`the split ${key} has no folds`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
folds.forEach((fold: Partial<SplitFold> | null, k) => {
|
|
165
|
+
const at = `the fold ${k} of the split ${key}`;
|
|
166
|
+
const { train, heldOut } = fold ?? {};
|
|
167
|
+
if (!isSeeds(train) || train.length === 0) out.push(`${at} has no train seeds`);
|
|
168
|
+
if (!isSeeds(heldOut) || heldOut.length === 0) out.push(`${at} has no heldOut seeds`);
|
|
169
|
+
if (!isSeeds(train) || !isSeeds(heldOut)) return;
|
|
170
|
+
const common = train.filter((s) => heldOut.includes(s));
|
|
171
|
+
if (common.length > 0) out.push(`${at} has the seeds ${common.join(", ")} in train and in heldOut`);
|
|
172
|
+
const unknown = [...train, ...heldOut].filter((s) => !seeds.has(s));
|
|
173
|
+
if (unknown.length > 0) out.push(`${at} has the seeds ${unknown.join(", ")}, which are not in the corpus`);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (name !== undefined && !Object.hasOwn(f.splits, name)) out.push(`the split file has no split ${name}. Its splits are ${Object.keys(f.splits).join(", ")}`);
|
|
177
|
+
return done();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* This function gives a copy of the folds of the split `name` of a split file. `where` names the file in the text of an error.
|
|
182
|
+
* It throws an `Error` for each problem of `checkSplit`, and then for an unknown split name.
|
|
183
|
+
*/
|
|
184
|
+
export function splitFolds(split: unknown, corpus: SplitCorpus, name: string, where: string = "The split file"): SplitFold[] {
|
|
185
|
+
const breaks = checkSplit(split, corpus);
|
|
186
|
+
if (breaks.length > 0) throw new Error(`${where} is not good: ${breaks.map((b) => b.detail).join("; ")}.`);
|
|
187
|
+
const splits = (split as CorpusSplit).splits;
|
|
188
|
+
const found = Object.hasOwn(splits, name) ? splits[name] : undefined;
|
|
189
|
+
if (found === undefined) throw new Error(`${where} has no split ${name}. Its splits are ${Object.keys(splits).join(", ")}.`);
|
|
190
|
+
return found.folds.map((f) => ({ train: [...f.train], heldOut: [...f.heldOut] }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** This function gives the folds that hold out one seed each, in the order of the seeds. Each fold trains on the other seeds. */
|
|
194
|
+
export function leaveOneSeedOut(seeds: readonly number[]): SplitFold[] {
|
|
195
|
+
const sorted = [...new Set(seeds)].sort((a, b) => a - b);
|
|
196
|
+
return sorted.map((s) => ({ train: sorted.filter((t) => t !== s), heldOut: [s] }));
|
|
197
|
+
}
|
package/src/index.ts
CHANGED
package/src/portfolio.ts
CHANGED
|
@@ -38,6 +38,11 @@ export interface PortfolioOptions {
|
|
|
38
38
|
readonly maxMembers?: number;
|
|
39
39
|
/** The clock in milliseconds. The default is `performance.now`, or `Date.now` without `performance`. */
|
|
40
40
|
readonly now?: () => number;
|
|
41
|
+
/**
|
|
42
|
+
* The members that get an output hash. The default `all` hashes each member with the status `ran`. A hash that throws then gives the status `error`.
|
|
43
|
+
* With `pick`, only member 0 and the winner get a hash, and only when the winner is not member 0. Then a hash that throws makes `pick` throw.
|
|
44
|
+
*/
|
|
45
|
+
readonly hashes?: "all" | "pick";
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/** The status of a member in a pick. */
|
|
@@ -51,7 +56,10 @@ export interface MemberTrace {
|
|
|
51
56
|
readonly values?: Readonly<Record<string, number>>;
|
|
52
57
|
/** The time of the run of the member, in milliseconds. */
|
|
53
58
|
readonly ms?: number;
|
|
54
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* The hash of the output of the member. Under the option `hashes` "all" (the default), each member with the status `ran` has it.
|
|
61
|
+
* Under `hashes` "pick", it is absent, except for member 0 and the winner when the winner is not member 0.
|
|
62
|
+
*/
|
|
55
63
|
readonly outputHash?: string;
|
|
56
64
|
/** The message of the error of a member with the status `error`. */
|
|
57
65
|
readonly error?: string;
|
|
@@ -171,7 +179,7 @@ function checkMembers(id: string, members: readonly Member<unknown, unknown>[]):
|
|
|
171
179
|
}
|
|
172
180
|
|
|
173
181
|
/** The result of the run of one member. */
|
|
174
|
-
type Ran<O> = { readonly ok: true; readonly output: O; readonly values: Record<string, number>; readonly hash
|
|
182
|
+
type Ran<O> = { readonly ok: true; readonly output: O; readonly values: Record<string, number>; readonly hash?: string } | { readonly ok: false; readonly error: unknown };
|
|
175
183
|
|
|
176
184
|
/**
|
|
177
185
|
* This function makes a portfolio. Its `pick` starts the members in their order. `selectBest` picks the winner of the members with the status `ran`.
|
|
@@ -197,14 +205,24 @@ export function portfolio<I, O>(
|
|
|
197
205
|
if (levelIds.has(level.id)) throw new RangeError(`The portfolio "${id}" has two levels with the id "${level.id}".`);
|
|
198
206
|
levelIds.add(level.id);
|
|
199
207
|
}
|
|
200
|
-
const { budgetMs, maxMembers } = options;
|
|
208
|
+
const { budgetMs, maxMembers, hashes = "all" } = options;
|
|
201
209
|
if (budgetMs !== undefined && !(budgetMs >= 0)) throw new RangeError(`The portfolio "${id}" has the budgetMs ${budgetMs}. It must be 0 or more.`);
|
|
202
210
|
if (maxMembers !== undefined && !(Number.isInteger(maxMembers) && maxMembers >= 1))
|
|
203
211
|
throw new RangeError(`The portfolio "${id}" has the maxMembers ${maxMembers}. It must be a whole number of 1 or more.`);
|
|
212
|
+
if (hashes !== "all" && hashes !== "pick") throw new RangeError(`The portfolio "${id}" has the hashes ${String(hashes)}. It must be "all" or "pick".`);
|
|
204
213
|
if (typeof members !== "function") checkMembers(id, members);
|
|
205
214
|
const levels: readonly SelectLevel[] = [{ id: "hard", spec: { direction: "lower", minEffect: 0 } }, ...key.levels.map((l) => ({ id: l.id, spec: l.spec }))];
|
|
206
215
|
const now = options.now ?? defaultClock();
|
|
207
216
|
|
|
217
|
+
/** This function gives the hash of the output of a member. A hash that throws causes a `RangeError`. */
|
|
218
|
+
const hashOf = (m: Member<I, O>, output: O): string => {
|
|
219
|
+
try {
|
|
220
|
+
return key.hash ? key.hash(output) : outputHash(output);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new RangeError(`The hash of the member "${m.id}" of the portfolio "${id}" throws: ${messageOf(e)}`, { cause: e });
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
208
226
|
const evaluate = (m: Member<I, O>, input: I, output: O): Ran<O> => {
|
|
209
227
|
const values: Record<string, number> = {};
|
|
210
228
|
const measure = (level: string, f: () => number): void => {
|
|
@@ -221,13 +239,8 @@ export function portfolio<I, O>(
|
|
|
221
239
|
try {
|
|
222
240
|
measure("hard", () => key.hard(input, output));
|
|
223
241
|
for (const level of key.levels) measure(level.id, () => level.value(input, output));
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
hash = key.hash ? key.hash(output) : outputHash(output);
|
|
227
|
-
} catch (e) {
|
|
228
|
-
throw new RangeError(`The hash of the member "${m.id}" of the portfolio "${id}" throws: ${messageOf(e)}`, { cause: e });
|
|
229
|
-
}
|
|
230
|
-
return { ok: true, output, values, hash };
|
|
242
|
+
if (hashes === "pick") return { ok: true, output, values };
|
|
243
|
+
return { ok: true, output, values, hash: hashOf(m, output) };
|
|
231
244
|
} catch (e) {
|
|
232
245
|
return { ok: false, error: e };
|
|
233
246
|
}
|
|
@@ -268,7 +281,11 @@ export function portfolio<I, O>(
|
|
|
268
281
|
result = { ok: false, error: e };
|
|
269
282
|
}
|
|
270
283
|
if (result.ok) {
|
|
271
|
-
traces.push(
|
|
284
|
+
traces.push(
|
|
285
|
+
result.hash === undefined
|
|
286
|
+
? { id: m.id, status: "ran", values: result.values, ms }
|
|
287
|
+
: { id: m.id, status: "ran", values: result.values, ms, outputHash: result.hash },
|
|
288
|
+
);
|
|
272
289
|
outputs.set(m.id, result.output);
|
|
273
290
|
ran.push({ id: m.id, values: result.values });
|
|
274
291
|
} else {
|
|
@@ -279,6 +296,10 @@ export function portfolio<I, O>(
|
|
|
279
296
|
if (ran.length === 0) throw firstError!.error;
|
|
280
297
|
const { winner, kept } = selectBest(ran, levels);
|
|
281
298
|
const winnerIndex = traces.findIndex((t) => t.id === winner);
|
|
299
|
+
if (hashes === "pick" && winnerIndex > 0 && traces[0]!.status === "ran") {
|
|
300
|
+
// Only member 0 and the winner get a hash. Their two trace entries are new objects with the hash.
|
|
301
|
+
for (const i of [0, winnerIndex]) traces[i] = { ...traces[i]!, outputHash: hashOf(list[i]!, outputs.get(traces[i]!.id)!) };
|
|
302
|
+
}
|
|
282
303
|
const base = traces[0]!;
|
|
283
304
|
const picked = winnerIndex > 0 && (base.status !== "ran" || traces[winnerIndex]!.outputHash !== base.outputHash);
|
|
284
305
|
const trace: PortfolioTrace = {
|