@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/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
@@ -11,3 +11,4 @@ export * from "./random.ts";
11
11
  export * from "./edges.ts";
12
12
  export * from "./hash.ts";
13
13
  export * from "./portfolio.ts";
14
+ export * from "./corpus.ts";
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
- /** The hash of the output of the member. */
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: string } | { readonly ok: false; readonly error: unknown };
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
- let hash: string;
225
- try {
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({ id: m.id, status: "ran", values: result.values, ms, outputHash: result.hash });
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 = {