@mark1russell7/layout-contract 0.2.15 → 0.2.17

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/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.15",
4
+ "version": "0.2.17",
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/families.ts CHANGED
@@ -30,6 +30,12 @@ export interface CorpusDimension extends SpaceParam {
30
30
  * Then `generate` of a registry removes these fields from the output, and lists them in `invalidated`.
31
31
  */
32
32
  readonly invalidates?: readonly string[];
33
+ /**
34
+ * The count of decimal places that the family uses for a value of a numeric dimension: a whole number of 0 or more.
35
+ * Only the `invalidates` compare reads it. The registry rounds the value and the default to it before the compare.
36
+ * The cells, the cell keys, `rngOf` and the value that `generate` gets do not change.
37
+ */
38
+ readonly precision?: number;
33
39
  }
34
40
 
35
41
  /**
@@ -226,13 +232,14 @@ export const CORPUS_DIMENSIONS: Readonly<Record<string, CorpusDimension>> = Obje
226
232
 
227
233
  /**
228
234
  * This function gives a dimension of `CORPUS_DIMENSIONS`, with the fields of `patch` over it. It throws a `RangeError` for an unknown name.
229
- * With `supported` or `invalidates` in the patch, it also throws a `RangeError` for a bad dimension, as `ownerDimension` does.
235
+ * With `supported`, `invalidates` or `precision` in the patch, it also throws a `RangeError` for a bad dimension, as `ownerDimension` does.
230
236
  */
231
237
  export function sharedDimension(name: string, patch: Partial<CorpusDimension> = {}): CorpusDimension {
232
238
  const base = Object.hasOwn(CORPUS_DIMENSIONS, name) ? CORPUS_DIMENSIONS[name] : undefined;
233
239
  if (base === undefined) throw new RangeError(`The shared dimension table has no dimension "${name}".`);
234
240
  const dim: CorpusDimension = Object.freeze({ ...base, ...patch, name });
235
- if (patch.supported !== undefined || patch.invalidates !== undefined) throwProblems(`sharedDimension: the dimension "${name}"`, ownProblems(dim));
241
+ if (patch.supported !== undefined || patch.invalidates !== undefined || patch.precision !== undefined)
242
+ throwProblems(`sharedDimension: the dimension "${name}"`, ownProblems(dim));
236
243
  return dim;
237
244
  }
238
245
 
@@ -241,7 +248,7 @@ export type OwnerDimensionSpec = Omit<CorpusDimension, "name">;
241
248
 
242
249
  /**
243
250
  * This function gives a dimension that only its owner declares, for example `cut` of a family of `packing`.
244
- * It throws a `RangeError` for a name of `CORPUS_DIMENSIONS`, the reserved name `kind`, or a bad default, level, `supported` or `invalidates`.
251
+ * It throws a `RangeError` for a name of `CORPUS_DIMENSIONS`, the reserved name `kind`, or a bad default, level, `supported`, `invalidates` or `precision`.
245
252
  * The registry examines the `when` of the dimension, because it needs the earlier dimensions.
246
253
  */
247
254
  export function ownerDimension(name: string, spec: OwnerDimensionSpec): CorpusDimension {
@@ -288,7 +295,7 @@ function supportedText(dim: CorpusDimension): string {
288
295
  return `the values from ${s.min ?? dim.min} to ${s.max ?? dim.max}`;
289
296
  }
290
297
 
291
- /** This function gives the problems of one dimension that need no other dimension: the default, the levels, `supported` and `invalidates`. */
298
+ /** This function gives the problems of one dimension that need no other dimension: the default, the levels, `supported`, `invalidates` and `precision`. */
292
299
  function ownProblems(d: CorpusDimension): string[] {
293
300
  const out: string[] = [];
294
301
  const bad = paramProblem(d, d.default);
@@ -299,6 +306,7 @@ function ownProblems(d: CorpusDimension): string[] {
299
306
  }
300
307
  out.push(...supportProblems(d, bad === undefined));
301
308
  out.push(...invalidatesProblems(d));
309
+ out.push(...precisionProblems(d));
302
310
  return out;
303
311
  }
304
312
 
@@ -343,6 +351,25 @@ function invalidatesProblems(d: CorpusDimension): string[] {
343
351
  return out;
344
352
  }
345
353
 
354
+ /** This function gives the problems of the `precision` of a dimension. A bad value and a dimension that is not numeric are problems. */
355
+ function precisionProblems(d: CorpusDimension): string[] {
356
+ const p: unknown = d.precision;
357
+ if (p === undefined) return [];
358
+ if (typeof p !== "number" || !Number.isInteger(p) || p < 0) return [`the precision of "${d.name}" is ${JSON.stringify(p)}, not a whole number of 0 or more`];
359
+ if (d.kind !== "int" && d.kind !== "real") return [`the precision of "${d.name}" is for a numeric dimension, but the dimension is a ${d.kind}`];
360
+ return [];
361
+ }
362
+
363
+ /** This function rounds a number to `places` decimal places: `Math.round(v * 10 ** places) / 10 ** places`. */
364
+ const roundTo = (v: number, places: number): number => Math.round(v * 10 ** places) / 10 ** places;
365
+
366
+ /** This function tells if a value is the default of a dimension. With `precision`, it compares the two numbers after it rounds them. */
367
+ function isDefault(d: CorpusDimension, value: ParamValue | undefined): boolean {
368
+ const def = paramValue(d, d.default);
369
+ if (d.precision === undefined || typeof value !== "number" || typeof def !== "number") return value === def;
370
+ return roundTo(value, d.precision) === roundTo(def, d.precision);
371
+ }
372
+
346
373
  /** This function tells if a log scale of a range starts at 0. Then the scale is the log of `1 + x - min`. */
347
374
  const fromZero = (d: SpaceParam): boolean => d.scale === "log" && (d.min ?? 0) <= 0;
348
375
 
@@ -501,12 +528,15 @@ function sweepLevels(dimensions: readonly CorpusDimension[], options: CellsOptio
501
528
  });
502
529
  }
503
530
 
504
- /** This function gives the output fields that the values make wrong: the `invalidates` of each dimension with a value other than its default, sorted. */
531
+ /**
532
+ * This function gives the output fields that the values make wrong: the `invalidates` of each dimension with a value other than its default, sorted.
533
+ * A dimension with `precision` compares the rounded value with the rounded default.
534
+ */
505
535
  function invalidFields(dimensions: readonly CorpusDimension[], full: DimensionValues): string[] {
506
536
  const out = new Set<string>();
507
537
  for (const d of dimensions) {
508
538
  if (d.invalidates === undefined || d.invalidates.length === 0) continue;
509
- if (full[d.name] !== paramValue(d, d.default)) for (const f of d.invalidates) out.add(f);
539
+ if (!isDefault(d, full[d.name])) for (const f of d.invalidates) out.add(f);
510
540
  }
511
541
  return [...out].sort(byCode);
512
542
  }
package/src/geometry.ts CHANGED
@@ -80,6 +80,34 @@ export function boundsOf(region: Region): Rect {
80
80
  }
81
81
  }
82
82
 
83
+ /**
84
+ * This function gives the exact area of a region. A `rect` gives `w` by `h`, a circle gives `PI * r * r`, and an ellipse gives `PI * rx * ry`.
85
+ * A polygon gives the absolute value of the shoelace formula, less the area of each hole. A ring with fewer than 3 points has the area 0.
86
+ */
87
+ export function regionArea(region: Region): number {
88
+ switch (region.kind) {
89
+ case "rect":
90
+ return region.w * region.h;
91
+ case "circle":
92
+ return Math.PI * region.r * region.r;
93
+ case "ellipse":
94
+ return Math.PI * region.rx * region.ry;
95
+ case "polygon":
96
+ return (region.holes ?? []).reduce((area, hole) => area - ringArea(hole), ringArea(region.points));
97
+ }
98
+ }
99
+
100
+ /** This function gives the area of one ring of a polygon: the absolute value of the shoelace formula. */
101
+ function ringArea(ring: readonly (readonly [number, number])[]): number {
102
+ let twice = 0;
103
+ for (let i = 0; i < ring.length; i++) {
104
+ const [ax, ay] = ring[i]!;
105
+ const [bx, by] = ring[(i + 1) % ring.length]!;
106
+ twice += ax * by - bx * ay;
107
+ }
108
+ return Math.abs(twice) / 2;
109
+ }
110
+
83
111
  /** This function gives the region moved by `dx` and `dy`. */
84
112
  export function translate(region: Region, dx: number, dy: number): Region {
85
113
  switch (region.kind) {
package/src/measures.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SHAPE_KIND_IDS, type ShapeKind } from "./shapes.ts";
1
+ import { SHAPE_KIND_IDS, type ShapeBound, type ShapeKind } from "./shapes.ts";
2
2
  import { RECORD_EPS, RECORD_SCALE } from "./tolerance.ts";
3
3
 
4
4
  /** The direction of a measure: the side of the better values. */
@@ -32,6 +32,11 @@ export interface MeasureSpec<Id extends string = string> {
32
32
  readonly role?: "guard" | "change-guard" | "diagnostic" | "time";
33
33
  /** The shape kinds of the boxes that the measure supports. Without it, the measure supports only `rect`. `checkShapes` refuses the other kinds. */
34
34
  readonly shapes?: readonly ShapeKind[];
35
+ /**
36
+ * The kinds where the value is a bound of the true value, and its side. A kind is in `shapes` or in `bound.shapes`, not in the two lists.
37
+ * `checkShapes` refuses a bound kind by default and with `{ level: "safe" }`. With `{ level: "bound" }`, it accepts it. `docs/SHAPES.md` tells what a bound value proves for each `direction`.
38
+ */
39
+ readonly bound?: ShapeBound;
35
40
  }
36
41
 
37
42
  /** The id of a joint measure. `docs/JOINT-MEASURES.md` gives the definitions. */
package/src/rules.ts CHANGED
@@ -132,11 +132,12 @@ export interface PackHard {
132
132
  /**
133
133
  * The hard rules of a pack, with the shape kinds that each rule supports.
134
134
  * `no-overlap` and `in-container` test the bounding box of a shape. That count is exact only for a `rect`.
135
+ * For the other kinds, the count is safe: a shape is inside its bounding box, so the count is an upper bound. A count of 0 tells that the rule has no break.
135
136
  * `locked-kept` and `complete` read only the positions and the ids, so they support each kind.
136
137
  */
137
138
  export const PACK_HARD_RULES: readonly ShapeSupport<keyof PackHard>[] = Object.freeze([
138
- { id: "no-overlap", shapes: ["rect"] },
139
- { id: "in-container", shapes: ["rect"] },
139
+ { id: "no-overlap", shapes: ["rect"], safe: ["polygon", "circle", "free"] },
140
+ { id: "in-container", shapes: ["rect"], safe: ["polygon", "circle", "free"] },
140
141
  { id: "locked-kept", shapes: SHAPE_KIND_IDS },
141
142
  { id: "complete", shapes: SHAPE_KIND_IDS },
142
143
  ] as const);
package/src/shapes.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The shape kinds of the items (`docs/SHAPES.md`): the declared set, the kind of a box and the check of the measures and rules on a run.
2
2
  import type { ClaimBreak } from "./corpus.ts";
3
- import type { Region } from "./geometry.ts";
3
+ import { regionArea, type Region } from "./geometry.ts";
4
4
  import type { LayoutBox, LayoutInput } from "./input.ts";
5
5
 
6
6
  /**
@@ -13,7 +13,7 @@ export type ShapeKind = "rect" | "polygon" | "circle" | "free";
13
13
  export interface ShapeGeometry {
14
14
  /** The bounding box: `boundsOf` and `placedRect`. */
15
15
  readonly bounds: boolean;
16
- /** The area of the shape. */
16
+ /** The area of the shape: `regionArea` and `boxArea`. */
17
17
  readonly area: boolean;
18
18
  /** The test that the shape is inside a region: `rectInRegion` for a `rect`. */
19
19
  readonly contains: boolean;
@@ -31,11 +31,14 @@ export interface ShapeKindSpec {
31
31
  readonly geometry: ShapeGeometry;
32
32
  }
33
33
 
34
- /** The declared shape kinds, in their order. Only `rect` has all of its geometry. The other kinds have their bounding box only. */
34
+ /**
35
+ * The declared shape kinds, in their order. Only `rect` has all of its geometry.
36
+ * `polygon` and `circle` have their bounding box and their exact area. `free` has its bounding box only.
37
+ */
35
38
  export const SHAPE_KINDS: readonly ShapeKindSpec[] = Object.freeze([
36
39
  { id: "rect", fields: ["w", "h"], regions: ["rect"], geometry: { bounds: true, area: true, contains: true, overlap: true } },
37
- { id: "polygon", fields: ["points", "holes?"], regions: ["polygon"], geometry: { bounds: true, area: false, contains: false, overlap: false } },
38
- { id: "circle", fields: ["cx", "cy", "r"], regions: ["circle"], geometry: { bounds: true, area: false, contains: false, overlap: false } },
40
+ { id: "polygon", fields: ["points", "holes?"], regions: ["polygon"], geometry: { bounds: true, area: true, contains: false, overlap: false } },
41
+ { id: "circle", fields: ["cx", "cy", "r"], regions: ["circle"], geometry: { bounds: true, area: true, contains: false, overlap: false } },
39
42
  { id: "free", fields: ["w", "h", "shape?"], regions: ["rect", "polygon", "circle", "ellipse"], geometry: { bounds: true, area: false, contains: false, overlap: false } },
40
43
  ] as const);
41
44
 
@@ -48,7 +51,87 @@ export const DEFAULT_SHAPES: readonly ShapeKind[] = Object.freeze(["rect"] as co
48
51
  /** A measure or a rule, with the shape kinds that it supports. Without `shapes`, it supports `DEFAULT_SHAPES`. */
49
52
  export interface ShapeSupport<Id extends string = string> {
50
53
  readonly id: Id;
54
+ /** The kinds with an exact count or value. */
51
55
  readonly shapes?: readonly ShapeKind[];
56
+ /**
57
+ * The kinds with a safe count: the rule can count a break that is not real, but it does not miss a real break.
58
+ * Thus a safe count of 0 tells that the rule has no break. A kind is in `shapes` or in `safe`, not in the two lists.
59
+ */
60
+ readonly safe?: readonly ShapeKind[];
61
+ /**
62
+ * The kinds where the value of a measure is a bound of the true value, not the exact value. `ShapeBound` tells the side.
63
+ * A kind is in one list only: `shapes`, `safe` or `bound.shapes`. `docs/SHAPES.md` tells what a bound value proves.
64
+ */
65
+ readonly bound?: ShapeBound;
66
+ }
67
+
68
+ /**
69
+ * The kinds where a measure gives a bound of the true value, and the side of the bound.
70
+ * With `upper`, the value is the true value or more. With `lower`, the value is the true value or less.
71
+ */
72
+ export interface ShapeBound {
73
+ readonly shapes: readonly ShapeKind[];
74
+ readonly side: "upper" | "lower";
75
+ }
76
+
77
+ /** The level of a rule on a shape kind: an exact count, a safe count (an upper bound), or no support. */
78
+ export type RuleLevel = "exact" | "safe" | "none";
79
+
80
+ /** This function gives the level of a rule on a shape kind. A kind of `shapesOf` is `exact`, a kind of `safe` is `safe`, and another kind is `none`. It does not read `bound`. */
81
+ export function ruleLevel(rule: Pick<ShapeSupport, "shapes" | "safe">, kind: ShapeKind): RuleLevel {
82
+ if (shapesOf(rule).includes(kind)) return "exact";
83
+ return rule.safe?.includes(kind) === true ? "safe" : "none";
84
+ }
85
+
86
+ /** The level of a measure or a rule on a shape kind. The level `bound` is a value on one side of the true value (`ShapeBound`). */
87
+ export type ShapeLevel = RuleLevel | "bound";
88
+
89
+ /**
90
+ * This function gives the level of a measure or a rule on a shape kind. It joins the lists `shapes`, `safe` and `bound.shapes`.
91
+ * A kind of `shapesOf` is `exact`, a kind of `safe` is `safe`, a kind of `bound.shapes` is `bound`, and another kind is `none`.
92
+ */
93
+ export function shapeLevel(use: Pick<ShapeSupport, "shapes" | "safe" | "bound">, kind: ShapeKind): ShapeLevel {
94
+ const level = ruleLevel(use, kind);
95
+ if (level !== "none") return level;
96
+ return use.bound?.shapes.includes(kind) === true ? "bound" : "none";
97
+ }
98
+
99
+ /**
100
+ * This function gives the problems of a list of measures and rules. An empty list tells that the list is good.
101
+ * It refuses an unknown kind, a kind two times in one list, and a kind in more than one of `shapesOf`, `safe` and `bound.shapes`.
102
+ * It also refuses a `bound` without a list of kinds, and a `side` that is not `upper` or `lower`.
103
+ */
104
+ export function shapeSupportProblems(uses: readonly ShapeSupport[]): string[] {
105
+ const out: string[] = [];
106
+ for (const use of uses) {
107
+ const bound: unknown = use.bound;
108
+ const isBound = typeof bound === "object" && bound !== null && !Array.isArray(bound);
109
+ if (bound !== undefined && !isBound) out.push(`${use.id}: the bound value is not an object with shapes and a side`);
110
+ const boundShapes = isBound ? (bound as { readonly shapes?: unknown }).shapes : undefined;
111
+ for (const [name, list] of [["shapes", use.shapes], ["safe", use.safe], ["bound", boundShapes]] as const) {
112
+ if (list === undefined && (name !== "bound" || !isBound)) continue;
113
+ if (!Array.isArray(list)) {
114
+ out.push(`${use.id}: the ${name === "bound" ? "bound shapes" : name} value is not a list of shape kinds`);
115
+ continue;
116
+ }
117
+ list.forEach((k: unknown, i) => {
118
+ if (!isShapeKind(k)) out.push(`${use.id}: the ${name} kind ${String(k)} is not a declared kind`);
119
+ else if (list.indexOf(k) !== i) out.push(`${use.id}: the ${name} list has the kind ${k} two times`);
120
+ });
121
+ }
122
+ const exact: readonly unknown[] = use.shapes === undefined ? DEFAULT_SHAPES : Array.isArray(use.shapes) ? use.shapes : [];
123
+ if (Array.isArray(use.safe)) for (const k of new Set<unknown>(use.safe)) if (exact.includes(k)) out.push(`${use.id}: the kind ${String(k)} is in shapes and in safe`);
124
+ if (!isBound) continue;
125
+ const side: unknown = (bound as { readonly side?: unknown }).side;
126
+ if (side !== "upper" && side !== "lower") out.push(`${use.id}: the bound side ${String(side)} is not upper or lower`);
127
+ if (!Array.isArray(boundShapes)) continue;
128
+ const safe: readonly unknown[] = Array.isArray(use.safe) ? use.safe : [];
129
+ for (const k of new Set<unknown>(boundShapes)) {
130
+ if (exact.includes(k)) out.push(`${use.id}: the kind ${String(k)} is in shapes and in bound`);
131
+ if (safe.includes(k)) out.push(`${use.id}: the kind ${String(k)} is in safe and in bound`);
132
+ }
133
+ }
134
+ return out;
52
135
  }
53
136
 
54
137
  const REGION_KIND: Readonly<Record<Region["kind"], ShapeKind>> = { rect: "rect", polygon: "polygon", circle: "circle", ellipse: "free" };
@@ -67,6 +150,19 @@ export function shapeKindOf(box: LayoutBox): ShapeKind {
67
150
  return box.shape === undefined ? "rect" : REGION_KIND[box.shape.kind];
68
151
  }
69
152
 
153
+ /**
154
+ * This function gives the exact area of a box, or `undefined` when the contract knows only its bounding box.
155
+ * A box with `shape` gives `regionArea` of the shape. A `rect` box without `shape` gives `w` by `h`.
156
+ * A box of the kind `free` gives `undefined`, also with a shape. Thus a box with an ellipse shape gives `undefined`, because `shapeKindOf` makes an ellipse `free`.
157
+ * A box of another kind without `shape` is a bad input, and gives `undefined`.
158
+ */
159
+ export function boxArea(box: LayoutBox): number | undefined {
160
+ const kind = shapeKindOf(box);
161
+ if (kind === "free") return undefined;
162
+ if (box.shape !== undefined) return regionArea(box.shape);
163
+ return kind === "rect" ? box.w * box.h : undefined;
164
+ }
165
+
70
166
  /** This function gives the shape kinds of the boxes of an input, in the order of `SHAPE_KINDS`. An input without boxes gives an empty list. */
71
167
  export function shapeKindsOf(input: Pick<LayoutInput, "boxes">): ShapeKind[] {
72
168
  const seen = new Set(input.boxes.map(shapeKindOf));
@@ -95,22 +191,38 @@ export function shapesOf(use: Pick<ShapeSupport, "shapes">): readonly ShapeKind[
95
191
  return use.shapes ?? DEFAULT_SHAPES;
96
192
  }
97
193
 
194
+ /** The options of `checkShapes`. */
195
+ export interface ShapeCheck {
196
+ /**
197
+ * The lowest level that `checkShapes` accepts. The default `exact` refuses a kind with only a safe count or a bound value.
198
+ * The value `safe` also accepts a kind of `safe`, as in 0.2.16. Use it only when the run claims that a rule has no break.
199
+ * The value `bound` also accepts a kind of `safe` and a kind of `bound.shapes`. Use it only when the run reads each bound value for the proofs of its side (`docs/SHAPES.md`).
200
+ */
201
+ readonly level?: "exact" | "safe" | "bound";
202
+ }
203
+
98
204
  /**
99
205
  * This function checks that each measure and each rule of a run supports each shape kind of the input. An empty list tells that the run is good.
100
206
  * Each break has the claim `shape`, the id of the input, and the first 5 ids of the boxes of the kind.
207
+ * By default, a kind of `safe` or of `bound.shapes` is a break, because the result is not exact.
208
+ * With the level `safe`, a kind of `safe` is not a break. With the level `bound`, a kind of `safe` and a kind of `bound.shapes` are not a break.
101
209
  */
102
- export function checkShapes(input: Pick<LayoutInput, "id" | "boxes">, uses: readonly ShapeSupport[]): ClaimBreak[] {
210
+ export function checkShapes(input: Pick<LayoutInput, "id" | "boxes">, uses: readonly ShapeSupport[], options: ShapeCheck = {}): ClaimBreak[] {
103
211
  const kinds = shapeKindsOf(input);
104
212
  const out: ClaimBreak[] = [];
105
213
  for (const use of uses) {
106
214
  const shapes = shapesOf(use);
107
215
  for (const kind of kinds) {
108
- if (shapes.includes(kind)) continue;
216
+ const level = shapeLevel(use, kind);
217
+ const accepts = level === "exact" || (level === "safe" && (options.level === "safe" || options.level === "bound")) || (level === "bound" && options.level === "bound");
218
+ if (accepts) continue;
109
219
  const ids = input.boxes.filter((b) => shapeKindOf(b) === kind).map((b) => b.id);
110
220
  const shown = ids.slice(0, 5).join(", ") + (ids.length > 5 ? `, and ${ids.length - 5} more` : "");
221
+ const safe =
222
+ level === "safe" ? `. The count of ${use.id} on ${kind} is only safe` : level === "bound" ? `. The value of ${use.id} is only ${use.bound!.side === "upper" ? "an upper" : "a lower"} bound for ${kind}` : "";
111
223
  out.push({
112
224
  claim: "shape",
113
- detail: `${use.id} supports only the shape kinds ${shapes.join(", ") || "(none)"}, but the input ${input.id} has ${kind} boxes: ${shown}`,
225
+ detail: `${use.id} supports only the shape kinds ${shapes.join(", ") || "(none)"}, but the input ${input.id} has ${kind} boxes: ${shown}${safe}`,
114
226
  inputs: [input.id],
115
227
  });
116
228
  }
@@ -52,6 +52,11 @@ export interface MemberRule {
52
52
  * With `pool`, a config of the option has the members of one entry, and `spaceSize` counts each entry one time.
53
53
  */
54
54
  readonly pool?: readonly (readonly StrategyConfig[])[];
55
+ /**
56
+ * The view of the items that the members get. With `bounds`, the option gives its members the bounding box of each item.
57
+ * Then a shape filter checks the members with the kind `rect`, and the option itself with the kinds of the input. Without `view`, the members get the kinds of the input.
58
+ */
59
+ readonly view?: "bounds";
55
60
  }
56
61
 
57
62
  /** The inputs that an option supports. A missing field supports each value. */
@@ -315,6 +320,8 @@ function optionProblems(stage: SpaceStage, o: StageOption): Problem[] {
315
320
  const pool: unknown = o.members.pool;
316
321
  if (pool !== undefined && (!Array.isArray(pool) || pool.length === 0 || !pool.every((entry: unknown) => Array.isArray(entry))))
317
322
  out.push(["pool", `${at} has a pool that is not a list of one or more member lists`]);
323
+ const view: unknown = o.members.view;
324
+ if (view !== undefined && view !== "bounds") out.push(["view", `${at} has the member view ${show(view)}, not "bounds"`]);
318
325
  }
319
326
  return out;
320
327
  }
@@ -337,6 +344,19 @@ function supportsProblems(supports: unknown, at: string): Problem[] {
337
344
  const supportsShapes = (o: StageOption, shapes: readonly ShapeKind[] | undefined): boolean =>
338
345
  shapes === undefined || o.supports?.shape === undefined || shapes.every((k) => o.supports!.shape!.includes(k));
339
346
 
347
+ /** The shape kinds that the members of an option with `view: "bounds"` see: a bounding box is a `rect`. */
348
+ const BOUNDS_VIEW: readonly ShapeKind[] = Object.freeze(["rect"] as const);
349
+
350
+ /**
351
+ * This function gives the shape kinds of the members of a meta option. With `view: "bounds"`, a list with a kind becomes `BOUNDS_VIEW`.
352
+ * Without a list, or with an empty list, the value does not change.
353
+ */
354
+ const memberShapes = (rule: MemberRule, shapes: readonly ShapeKind[] | undefined): readonly ShapeKind[] | undefined =>
355
+ rule.view === "bounds" && shapes !== undefined && shapes.length > 0 ? BOUNDS_VIEW : shapes;
356
+
357
+ /** This function tells if two lists have the same shape kinds. */
358
+ const sameKinds = (a: readonly ShapeKind[], b: readonly ShapeKind[]): boolean => a.every((k) => b.includes(k)) && b.every((k) => a.includes(k));
359
+
340
360
  /** This function gives the problems of the params of one stage choice. */
341
361
  function choiceParamProblems(o: StageOption, params: unknown, at: string): Problem[] {
342
362
  if (params !== undefined && !isObject(params)) return [["config", `the params of ${at} are not an object`]];
@@ -411,7 +431,7 @@ function configProblems(space: StrategySpace, config: unknown, member: boolean,
411
431
  out.push(["depth", `a member has the meta option ${s.name}.${o.id}. The depth of a meta config is 1`]);
412
432
  continue;
413
433
  }
414
- out.push(...memberProblems(partOf(space, s.name).space, o.members, c["members"], at, shapes));
434
+ out.push(...memberProblems(partOf(space, s.name).space, o.members, c["members"], at, memberShapes(o.members, shapes)));
415
435
  }
416
436
  for (const c of space.constraints ?? []) if (!holds(c, chosen)) out.push(["constraint", `the config breaks the constraint ${describe(c)}`]);
417
437
  return out;
@@ -473,6 +493,7 @@ const toBreaks = (problems: readonly Problem[], inputs: readonly string[] = []):
473
493
  *
474
494
  * A member list that is not an entry of the `pool` of its option gives the claim `pool`.
475
495
  * With `shapes`, an option that does not support a kind of the list gives the claim `shape`. A member of a meta option gets the same check.
496
+ * A member of an option with `view: "bounds"` gets the check with the kind `rect`.
476
497
  */
477
498
  export function checkConfig(space: StrategySpace, config: StrategyConfig, options: ShapeFilter = {}): ClaimBreak[] {
478
499
  return toBreaks(configProblems(space, config, false, options.shapes));
@@ -738,7 +759,9 @@ function combos(space: StrategySpace, levels: number, meta: boolean, limit: bigi
738
759
  for (const o of s.options) {
739
760
  if (o.members === undefined) continue;
740
761
  const choices = new Map<string, StageOption | null>(stages.map((x) => [x.name, x === s ? o : null]));
741
- const lists = o.members.pool === undefined ? memberLists(base, o.members, limit) : BigInt(o.members.pool.length);
762
+ const own = memberSpaces.get(o);
763
+ const from = own === undefined ? base : baseCount(own, levels, limit);
764
+ const lists = o.members.pool === undefined ? memberLists(from, o.members, limit) : BigInt(o.members.pool.length);
742
765
  if (keeps(space, choices)) out.push({ choices, base: false, weight: times(countOf(o), lists, limit) });
743
766
  }
744
767
  return out;
@@ -761,6 +784,8 @@ export function spaceSize(space: StrategySpace, options: SpaceSizeOptions): Spac
761
784
  * This function gives the space with only the options that support each shape kind of `shapes`. Without `shapes`, it gives the same space.
762
785
  * A stage can lose all of its options. Then the space has no config with that stage.
763
786
  * A pool keeps only the entries whose members fit the smaller space. A meta option without such an entry goes out.
787
+ *
788
+ * The members of an option with `view: "bounds"` come from the space for the kinds of `memberShapes`. `memberSpaces` keeps that space.
764
789
  */
765
790
  function restrict(space: StrategySpace, shapes: readonly ShapeKind[] | undefined): StrategySpace {
766
791
  if (shapes === undefined) return space;
@@ -770,14 +795,61 @@ function restrict(space: StrategySpace, shapes: readonly ShapeKind[] | undefined
770
795
  ...(space.parts === undefined ? {} : { parts: space.parts.map((p) => restrict(p, shapes)) }),
771
796
  };
772
797
  const withPool = (s: SpaceStage, o: StageOption): StageOption[] => {
773
- if (o.members?.pool === undefined) return [o];
774
- const part = partOf(kept, s.name).space;
798
+ if (o.members === undefined) return [o];
799
+ const inner = memberShapes(o.members, shapes)!;
800
+ const own = sameKinds(inner, shapes) ? undefined : restrict(partOf(space, s.name).space, inner);
801
+ const mark = (option: StageOption): StageOption[] => {
802
+ if (own !== undefined) memberSpaces.set(option, own);
803
+ return [option];
804
+ };
805
+ if (o.members.pool === undefined) return own === undefined ? [o] : mark({ ...o });
806
+ const part = own ?? partOf(kept, s.name).space;
775
807
  const pool = o.members.pool.filter((entry) => fits(part, entry));
776
- return pool.length === 0 ? [] : [{ ...o, members: { ...o.members, pool } }];
808
+ return pool.length === 0 ? [] : mark({ ...o, members: { ...o.members, pool } });
777
809
  };
778
810
  return { ...kept, stages: kept.stages.map((s) => ({ ...s, options: s.options.flatMap((o) => withPool(s, o)) })) };
779
811
  }
780
812
 
813
+ /** The space of the members of a meta option of a restricted space, when it is not the part of the option. Only `view: "bounds"` makes such a space. */
814
+ const memberSpaces = new WeakMap<StageOption, StrategySpace>();
815
+
816
+ /**
817
+ * This function tells if an option of a restricted space has a member list. A base option and a meta option with a `pool` have one.
818
+ * A different meta option has one when its members have a base config. `restrict` keeps only the pool entries that fit.
819
+ */
820
+ const hasMembers = (space: StrategySpace, stage: string, o: StageOption): boolean =>
821
+ o.members === undefined || o.members.pool !== undefined || combos(memberSpaces.get(o) ?? partOf(space, stage).space, 1, false, 0n).length > 0;
822
+
823
+ /** This function tells if a restricted space has a config. A base config counts. With `meta`, a meta config counts when each of its meta options has a member list. */
824
+ const hasConfig = (space: StrategySpace, meta: boolean): boolean =>
825
+ combos(space, 1, meta, 0n).some((c) => c.base || [...c.choices].every(([stage, o]) => o === null || hasMembers(space, stage, o)));
826
+
827
+ /** This function gives the error of a sample of a space that has no config for the shape kinds `shapes`. `kept` is the restricted space. */
828
+ function noSample(space: StrategySpace, kept: StrategySpace, shapes: readonly ShapeKind[], meta: boolean): RangeError {
829
+ const list = (names: readonly string[], one: string, more: string): string => (names.length === 1 ? `${one} ${names[0]}` : `${more} ${names.join(", ")}`);
830
+ const empty = kept.stages.filter((s) => !s.optional && !s.options.some((o) => meta || o.members === undefined)).map((s) => s.name);
831
+ const removed = !meta
832
+ ? []
833
+ : space.stages.flatMap((s) =>
834
+ s.options
835
+ .filter((o) => o.members !== undefined && o.members.view !== "bounds" && supportsShapes(o, shapes))
836
+ .filter((o) => {
837
+ const own = kept.stages.find((x) => x.name === s.name)?.options.find((x) => x.id === o.id);
838
+ return own === undefined || !hasMembers(kept, s.name, own);
839
+ })
840
+ .map((o) => `${s.name}.${o.id}`),
841
+ );
842
+ let text = `The space ${space.engine} has no config for a sample with the shape kinds ${shapes.join(", ")}.`;
843
+ if (empty.length > 0) text += ` ${list(empty, "The stage", "The stages")} ${empty.length === 1 ? "has" : "have"} no option for these kinds.`;
844
+ if (removed.length > 0)
845
+ text += ` ${list(removed, "The meta option", "The meta options")} ${removed.length === 1 ? "has" : "have"} no member config for these kinds. Set members.view to "bounds" when the members see only the bounding boxes.`;
846
+ if (empty.length === 0 && removed.length === 0) text += " The constraints remove each config.";
847
+ return new RangeError(text);
848
+ }
849
+
850
+ /** This function gives the count of the base configs of a space that is known to be good. */
851
+ const baseCount = (space: StrategySpace, levels: number, limit: bigint): bigint => combos(space, levels, false, limit).reduce((sum, c) => sum + c.weight!, 0n);
852
+
781
853
  /** This function throws a `RangeError` for a list of shape kinds with a kind that is not declared. */
782
854
  function assertShapes(shapes: readonly ShapeKind[] | undefined): void {
783
855
  if (shapes === undefined) return;
@@ -854,16 +926,29 @@ function sampleValue(p: SpaceParam, u: number): ParamValue {
854
926
  /** This function gives a seed of 32 bits from a seed and a step name. */
855
927
  const subSeed = (seed: number, step: string): number => Math.floor(stepRandom(seed, step)() * 4294967296);
856
928
 
857
- /** This function gives a sample of a space: Latin hypercube strata over the options, the params and the member counts. */
858
- function sampleConfigs(space: StrategySpace, n: number, seed: number, meta: boolean, maxTries: number): StrategyConfig[] {
929
+ /**
930
+ * This function gives a sample of a space: Latin hypercube strata over the options, the params and the member counts.
931
+ * `canon` is the space of `canonicalConfig`: the space before a shape filter. A member of an option with `view: "bounds"` can have an option that the filter removed.
932
+ */
933
+ function sampleConfigs(space: StrategySpace, n: number, seed: number, meta: boolean, maxTries: number, canon: StrategySpace = space): StrategyConfig[] {
859
934
  const stages = space.stages;
860
935
  const prefix = prefixes(space);
936
+ // A stage without options can stay empty in a part with a meta option of `view: "bounds"`, because that option replaces the stage.
937
+ const bounded = new Set(stages.filter((s) => meta && s.options.some((o) => memberSpaces.has(o))).map((s) => prefix.get(s.name)!));
861
938
  const lists = stages.map((s) => {
862
939
  const list: (StageOption | null)[] = s.options.filter((o) => meta || o.members === undefined);
863
940
  if (s.optional) list.push(null);
941
+ if (list.length === 0 && bounded.has(prefix.get(s.name)!)) list.push(null);
864
942
  if (list.length === 0) throw new Error(`The stage ${s.name} of the space ${space.engine} has no option for a sample. A shape filter can remove all of its options.`);
865
943
  return list;
866
944
  });
945
+ // A meta option without a member list stays in its list, so the strata of 0.2.16 do not change. A point that chooses it draws again.
946
+ const usable = new Map<StageOption, boolean>();
947
+ const withMembers = (s: SpaceStage, o: StageOption | null | undefined): boolean => {
948
+ if (o === null || o === undefined || o.members === undefined) return true;
949
+ if (!usable.has(o)) usable.set(o, hasMembers(space, s.name, o));
950
+ return usable.get(o)!;
951
+ };
867
952
  const points: Map<string, StageOption | null>[] = [];
868
953
  let tries = 0;
869
954
  for (let round = 0; points.length < n; round++) {
@@ -882,7 +967,8 @@ function sampleConfigs(space: StrategySpace, n: number, seed: number, meta: bool
882
967
  const m = metaPart.get(prefix.get(s.name)!);
883
968
  if (m !== undefined && m !== s.name) choices.set(s.name, null);
884
969
  }
885
- if (keeps(space, choices)) points.push(choices);
970
+ const filled = stages.every((s) => s.optional === true || choices.get(s.name) !== null || metaPart.has(prefix.get(s.name)!));
971
+ if (filled && keeps(space, choices) && stages.every((s) => withMembers(s, choices.get(s.name)))) points.push(choices);
886
972
  }
887
973
  }
888
974
  const values: Values[][] = points.map(() => stages.map(() => ({})));
@@ -907,7 +993,7 @@ function sampleConfigs(space: StrategySpace, n: number, seed: number, meta: bool
907
993
  });
908
994
  continue;
909
995
  }
910
- const part = partOf(space, s.name).space;
996
+ const part = memberSpaces.get(o) ?? partOf(space, s.name).space;
911
997
  const poolSize = Math.max(n, 4 * o.members.max, 8);
912
998
  const seen = new Set<string>();
913
999
  const pool = sampleConfigs(part, poolSize, subSeed(seed, `pool:${s.name}:${o.id}`), false, Math.max(1000, 100 * poolSize)).filter((c) => {
@@ -930,7 +1016,7 @@ function sampleConfigs(space: StrategySpace, n: number, seed: number, meta: bool
930
1016
  const o = choices.get(s.name)!;
931
1017
  out[s.name] = o === null ? null : o.members === undefined ? { option: o.id, params: values[i]![k]! } : { option: o.id, params: values[i]![k]!, members: members[i]![k]! };
932
1018
  });
933
- return canonicalConfig(space, { space: space.engine, stages: out });
1019
+ return canonicalConfig(canon, { space: space.engine, stages: out });
934
1020
  });
935
1021
  }
936
1022
 
@@ -942,6 +1028,8 @@ function sampleConfigs(space: StrategySpace, n: number, seed: number, meta: bool
942
1028
  * - A sample draws again when a point breaks a constraint. After `maxTries` draws, it throws an `Error`. Two sampled configs can be equal.
943
1029
  * - With `shapes`, only the options that support each kind of the list count. Without `shapes`, the result does not change.
944
1030
  * - A meta option with a `pool` gives only the entries of its pool. With `shapes`, only the entries whose members support each kind count.
1031
+ * - A sample draws again when a point chooses a meta option without a member list. `spaceSize` gives such an option 0.
1032
+ * - With `shapes`, a sample throws a `RangeError` when the space has no config for the kinds. The error names the stages without an option and the meta options without members.
945
1033
  */
946
1034
  export function enumerateSpace(space: StrategySpace, options: EnumerateOptions): StrategyConfig[] {
947
1035
  assertSpace(space);
@@ -956,7 +1044,14 @@ export function enumerateSpace(space: StrategySpace, options: EnumerateOptions):
956
1044
  const meta = options.meta ?? "skip";
957
1045
  if (meta !== "skip" && meta !== "sample") throw new RangeError(`The option meta is ${String(meta)}, not skip or sample.`);
958
1046
  if (options.n === 0) return [];
959
- return sampleConfigs(restrict(space, options.shapes), options.n, options.seed, meta === "sample", options.maxTries ?? Math.max(1000, 100 * options.n));
1047
+ const kept = restrict(space, options.shapes);
1048
+ try {
1049
+ return sampleConfigs(kept, options.n, options.seed, meta === "sample", options.maxTries ?? Math.max(1000, 100 * options.n), space);
1050
+ } catch (error) {
1051
+ // Only a space without a config for the shape kinds gives the RangeError. A different failure keeps its own error.
1052
+ if (options.shapes !== undefined && !hasConfig(kept, meta === "sample")) throw noSample(space, kept, options.shapes, meta === "sample");
1053
+ throw error;
1054
+ }
960
1055
  }
961
1056
 
962
1057
  /**