@mark1russell7/highways 0.1.5 → 0.3.0

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.
Files changed (56) hide show
  1. package/README.md +293 -8
  2. package/build/index.d.ts +1774 -236
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +9307 -3090
  5. package/build/index.js.map +1 -1
  6. package/package.json +2 -1
  7. package/src/bench/compare.ts +145 -0
  8. package/src/bench/curves.ts +514 -2
  9. package/src/bench/links.ts +28 -6
  10. package/src/bench/measures/bundles.ts +119 -0
  11. package/src/bench/measures/curves.ts +211 -0
  12. package/src/bench/measures/groups.ts +122 -0
  13. package/src/bench/measures/index.ts +9 -0
  14. package/src/bench/measures/levels.ts +103 -0
  15. package/src/bench/measures/pareto.ts +37 -0
  16. package/src/bench/measures/room.ts +43 -0
  17. package/src/bench/measures/routes.ts +226 -0
  18. package/src/bench/measures/stability.ts +104 -0
  19. package/src/bench/measures/stats.ts +114 -0
  20. package/src/bench/properties.ts +18 -2
  21. package/src/bench/room.ts +209 -2
  22. package/src/bench/run.ts +86 -11
  23. package/src/bench/sections.ts +121 -0
  24. package/src/bench/suite.ts +781 -0
  25. package/src/cases.ts +41 -5
  26. package/src/curves/clothoid/index.ts +150 -0
  27. package/src/curves/corner.ts +116 -0
  28. package/src/curves/fit.ts +79 -0
  29. package/src/curves/highway/index.ts +636 -0
  30. package/src/curves/index.ts +16 -1
  31. package/src/curves/model.ts +4 -2
  32. package/src/curves/smooth/index.ts +50 -0
  33. package/src/curves/space.ts +76 -0
  34. package/src/groups/cut/index.ts +292 -0
  35. package/src/groups/index.ts +4 -0
  36. package/src/groups/kd/index.ts +207 -0
  37. package/src/groups/keys/index.ts +81 -0
  38. package/src/groups/quadtree/index.ts +10 -0
  39. package/src/index.ts +7 -0
  40. package/src/levels.ts +178 -9
  41. package/src/model.ts +2 -0
  42. package/src/pick.ts +36 -0
  43. package/src/room/bends.ts +203 -0
  44. package/src/room/index.ts +28 -0
  45. package/src/room/levels/index.ts +155 -0
  46. package/src/routers/sections.ts +78 -55
  47. package/src/routers/shared.ts +7 -2
  48. package/src/routers/trunks.ts +131 -6
  49. package/src/sections.ts +93 -4
  50. package/src/segments.ts +81 -31
  51. package/src/strategies/builtins.ts +153 -7
  52. package/src/strategies/index.ts +1 -0
  53. package/src/strategies/model.ts +29 -7
  54. package/src/strategies/options.ts +296 -0
  55. package/src/strategies/schemas.ts +221 -0
  56. package/src/visibility.ts +190 -1
@@ -0,0 +1,781 @@
1
+ import { packedGridBoard } from "../cases.ts";
2
+ import { curveSpace, sampleCurve, type CurveSegment } from "../curves/index.ts";
3
+ import { FIXTURES, makeFixture, type FixtureSpec } from "../fixtures.ts";
4
+ import { polylineLength } from "../geom.ts";
5
+ import { nodeIndex, type Graph, type Node, type Result } from "../model.ts";
6
+ import { nearestSide, SIDE_NORMAL } from "../plugs.ts";
7
+ import { makeRoom, openGaps, roomLinks } from "../room/index.ts";
8
+ import { sectionsRouter } from "../routers/sections.ts";
9
+ import { checkSections, type Section } from "../sections.ts";
10
+ import { STRATEGIES } from "../strategies/builtins.ts";
11
+ import { asRouter, listStrategies, STEPS, type Registry, type Step, type Strategy } from "../strategies/model.ts";
12
+ import { measureLevelFaults, sumFaults } from "./links.ts";
13
+ import { checkLevels } from "./properties.ts";
14
+ import { ambiguity, inkSaved } from "./measures/bundles.ts";
15
+ import { bendingEnergy, curveFaults, g1Defect, g2Defect } from "./measures/curves.ts";
16
+ import { balance, cutShare, farLevel, groupStability, levelGrouping, modularity, treeCost } from "./measures/groups.ts";
17
+ import { nestingFaults, orderStability, positionStability } from "./measures/levels.ts";
18
+ import { paretoFront, type Objective } from "./measures/pareto.ts";
19
+ import { roomOrderFaults } from "./measures/room.ts";
20
+ import { boardUnit, crossingAngles, detourSpread, drawnSegments, plugSpacing, totalTurn } from "./measures/routes.ts";
21
+ import { changeGraph, routeStability } from "./measures/stability.ts";
22
+ import { quantile, summarize, type Spread } from "./measures/stats.ts";
23
+ import { measureBoard } from "./room.ts";
24
+ import { benchRun, type BenchRow } from "./run.ts";
25
+
26
+ // The core runs in any host, so it reads the clock without the types of Node or the browser.
27
+ const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now();
28
+
29
+ /** The names of the proposed hard rules of MEAS0. The suite counts them. It does not apply them as rules yet. */
30
+ export const PROPOSED_RULES: readonly string[] = ["curve-clear", "curve-ends", "curve-g1", "curve-simple", "level-nesting", "room-order"];
31
+
32
+ /** The options of `runSuite`. */
33
+ export interface SuiteOptions {
34
+ /** The largest fixture, in nodes. The default is 1000. */
35
+ readonly max?: number;
36
+ /** The number of seeds of each fixture family. The default is 5. The first seed is the seed of the fixture. */
37
+ readonly seeds?: number;
38
+ /** A fixture family or a room family with more nodes than this number gets one seed only. The default is 1000. */
39
+ readonly seedsMaxNodes?: number;
40
+ /** The number of timed routes of the first seed. The other seeds route two times, for the rule "same twice". The default is 3. */
41
+ readonly repeat?: number;
42
+ /** The route stability (Z10) routes a changed graph again, so the suite measures it only up to this number of nodes. The default is 1000. */
43
+ readonly stabilityMax?: number;
44
+ /** The step `curve` draws the routes of the fixtures up to this number of nodes. The default is 1000. */
45
+ readonly curveMax?: number;
46
+ /** The router whose routes the step `curve` draws. The default is `corners`. */
47
+ readonly curveRouter?: string;
48
+ /** The curve strategies that are G1 by their design. The rule `curve-g1` examines them. The default is `rounded`, `arc`, `spline`, `smooth`, `clothoid` and `highway`. */
49
+ readonly smoothCurves?: readonly string[];
50
+ /** The curve strategies that read the normals of the plugs. The rule `curve-ends` examines their end tangents. The default is `spline`. */
51
+ readonly normalCurves?: readonly string[];
52
+ /** The strategies of each step. The default is the registry of the built-in strategies. */
53
+ readonly registry?: Registry;
54
+ /** The fixtures of the families of the steps `router`, `curve` and `group`. The default is `FIXTURES`. */
55
+ readonly fixtures?: readonly FixtureSpec[];
56
+ /** The families of the step `room`. The default is `ROOM_FAMILIES`. */
57
+ readonly roomFamilies?: readonly RoomFamily[];
58
+ /** The names of the routers of the step `router`. The default is each router of the registry. */
59
+ readonly routers?: readonly string[];
60
+ /** The steps that the suite runs. The default is each step. Each part of the workflow `Bench` runs some steps only. The step `curve` needs the step `router`. */
61
+ readonly steps?: readonly Step[];
62
+ /** The time limit of one cell of the step `router`, in milliseconds, as `BenchOptions.cellLimit` gives it. The default is no limit. */
63
+ readonly cellLimit?: number;
64
+ /** This function gets one line of progress for each fixture and seed. */
65
+ readonly log?: (line: string) => void;
66
+ }
67
+
68
+ /** A family of packed boards for the step `room`: `packedGridBoard` with a seed for each member. */
69
+ export interface RoomFamily {
70
+ readonly name: string;
71
+ readonly cols: number;
72
+ readonly rows: number;
73
+ /** The number of long links. */
74
+ readonly long: number;
75
+ }
76
+
77
+ /** The families of the step `room`: the board of ROOM, and a board of 1000 boxes. */
78
+ export const ROOM_FAMILIES: readonly RoomFamily[] = [
79
+ { name: "packed-grid", cols: 12, rows: 8, long: 48 },
80
+ { name: "packed-grid-1000", cols: 40, rows: 25, long: 500 },
81
+ ];
82
+
83
+ /** One row of the suite: one strategy of one step on one member of a fixture family. */
84
+ export interface SuiteRow {
85
+ readonly step: Step;
86
+ /** The name of the fixture family. */
87
+ readonly fixture: string;
88
+ /** The index of the seed in the family, from 0. */
89
+ readonly seed: number;
90
+ readonly strategy: string;
91
+ readonly nodes: number;
92
+ readonly edges: number;
93
+ /**
94
+ * The violations of the hard rules of today. The step `router` checks `checkProperties` and "same twice".
95
+ * The step `group` checks `checkSections` and the rules of the levels (`checkLevels`).
96
+ */
97
+ readonly violations: number;
98
+ /** The time of the step and of the measures of today, in milliseconds. */
99
+ readonly ms: number;
100
+ /** The time of the new measures of MEAS1, without the extra runs, in milliseconds. */
101
+ readonly measureMs: number;
102
+ /** The time of the extra routes of the stability measures (Z10 and G9), in milliseconds. */
103
+ readonly extraMs: number;
104
+ /** The time of the grouping rule alone, without the router, in milliseconds. Only the rows of the step `group` have it. */
105
+ readonly treeMs?: number;
106
+ /** The measures, by a key that starts with the id of MEAS0, or the name of a proposed rule. */
107
+ readonly values: Readonly<Record<string, number>>;
108
+ }
109
+
110
+ /** The spread of each measure of one strategy on one fixture family, over the seeds. */
111
+ export interface SuiteSummary {
112
+ readonly step: Step;
113
+ readonly fixture: string;
114
+ readonly strategy: string;
115
+ readonly nodes: number;
116
+ readonly seeds: number;
117
+ /** The sum of the violations over the seeds. */
118
+ readonly violations: number;
119
+ readonly measures: Readonly<Record<string, Spread>>;
120
+ }
121
+
122
+ /** The Pareto front of one step on one fixture family, over the medians of the seeds. */
123
+ export interface SuiteFront {
124
+ readonly step: Step;
125
+ readonly fixture: string;
126
+ readonly nodes: number;
127
+ readonly objectives: readonly Objective[];
128
+ /** The strategies on the front, in the order of the registry. */
129
+ readonly front: readonly string[];
130
+ /** The strategies with a violation of a hard rule. They do not enter the front. */
131
+ readonly excluded: readonly string[];
132
+ /** Each strategy that the front compares. */
133
+ readonly strategies: readonly string[];
134
+ }
135
+
136
+ /** The count of one proposed hard rule for one strategy, over each fixture and each seed. */
137
+ export interface RuleCount {
138
+ readonly step: Step;
139
+ readonly strategy: string;
140
+ readonly rule: string;
141
+ readonly count: number;
142
+ }
143
+
144
+ /** The report of the suite. The workflow `Bench` keeps it as an artifact. */
145
+ export interface SuiteReport {
146
+ readonly options: {
147
+ readonly max: number;
148
+ readonly seeds: number;
149
+ readonly seedsMaxNodes: number;
150
+ readonly repeat: number;
151
+ readonly stabilityMax: number;
152
+ readonly curveMax: number;
153
+ readonly curveRouter: string;
154
+ };
155
+ readonly rows: readonly SuiteRow[];
156
+ readonly summaries: readonly SuiteSummary[];
157
+ readonly fronts: readonly SuiteFront[];
158
+ readonly rules: readonly RuleCount[];
159
+ }
160
+
161
+ /** The axes of the Pareto front of each step. The note MEAS0 asks for two to four scores. */
162
+ export const FRONT_OBJECTIVES: Readonly<Record<Step, readonly Objective[]>> = {
163
+ router: [
164
+ { key: "R1.perEdge", better: "min" },
165
+ { key: "R5.median", better: "min" },
166
+ { key: "B1.inkSaved", better: "max" },
167
+ { key: "R4.turn", better: "min" },
168
+ ],
169
+ curve: [
170
+ { key: "C1.share", better: "min" },
171
+ { key: "C2.median", better: "min" },
172
+ { key: "C3.median", better: "min" },
173
+ { key: "C6.length", better: "min" },
174
+ ],
175
+ room: [
176
+ { key: "M1.area", better: "min" },
177
+ { key: "M4.laneThrough", better: "min" },
178
+ { key: "crossings", better: "min" },
179
+ ],
180
+ group: [
181
+ { key: "G1.modularity", better: "max" },
182
+ { key: "G2.cut", better: "min" },
183
+ { key: "G8.treeCost", better: "min" },
184
+ { key: "G9.ari", better: "max" },
185
+ ],
186
+ };
187
+
188
+ /** This function gives the seed of the member `k` of the family of a fixture. The member 0 is the fixture itself. */
189
+ export function familySeed(spec: FixtureSpec, k: number): number {
190
+ return k === 0 ? spec.seed : spec.seed + 1009 * k;
191
+ }
192
+
193
+ /** This function gives the measures of the levels of a result: Z6, Z7, Z8, and G1, G2 and G8 of the far view. A result without groups gives none. */
194
+ export function levelMeasures(graph: Graph, result: Result): Record<string, number> {
195
+ const levels = result.levels ?? [];
196
+ if (!levels.some((l) => l.items.some((i) => i.nodes.length > 1))) return {};
197
+ const far = farLevel(levels);
198
+ const groups = far ? levelGrouping(far) : new Map<string, string>();
199
+ return {
200
+ "level-nesting": nestingFaults(levels),
201
+ "Z4.farItems": far?.items.length ?? 0,
202
+ "Z7.position": positionStability(levels),
203
+ "Z8.order": orderStability(graph, levels),
204
+ "G1.modularity": modularity(graph, groups),
205
+ "G2.cut": cutShare(graph, groups),
206
+ "G6.balance": far ? balance(far) : 1,
207
+ "G8.treeCost": treeCost(graph, levels),
208
+ };
209
+ }
210
+
211
+ /** This function gives the new measures of the routes of a result (MEAS1). The row of `benchRun` gives the values of today. */
212
+ export function routeMeasures(graph: Graph, result: Result, row: BenchRow): Record<string, number> {
213
+ const routes = result.routes;
214
+ const m = Math.max(1, graph.edges.length);
215
+ const ca = crossingAngles(drawnSegments(routes));
216
+ const turn = totalTurn(routes);
217
+ const detour = detourSpread(routes);
218
+ const plugs = plugSpacing(graph, routes);
219
+ return {
220
+ "R1.crossings": row.quality.crossings,
221
+ "R1.perEdge": row.quality.crossings / m,
222
+ "R2.angle": ca.mean,
223
+ "R2.min": ca.min,
224
+ "B1.inkSaved": inkSaved(routes),
225
+ "R4.turn": turn.mean,
226
+ "R4.max": turn.max,
227
+ "R5.median": detour.median,
228
+ "R5.q95": detour.q95,
229
+ "R5.mean": detour.mean,
230
+ "R6.worstDetour": row.faults.worstDetour,
231
+ "R7.backtrack": row.faults.backtrack / m,
232
+ "R9.through": row.faults.through,
233
+ "R13.outside": row.faults.outside,
234
+ "R11.min": plugs.min,
235
+ "R11.q05": plugs.q05,
236
+ "B4.ambiguity": ambiguity(graph, routes).value,
237
+ ...levelMeasures(graph, result),
238
+ };
239
+ }
240
+
241
+ /** The input of one curve: a route with the normals of its plugs. */
242
+ interface CurveRoute {
243
+ readonly points: readonly { x: number; y: number }[];
244
+ readonly startNormal: { x: number; y: number };
245
+ readonly endNormal: { x: number; y: number };
246
+ }
247
+
248
+ /** The options of `curveRuns`. */
249
+ export interface CurveRunOptions {
250
+ /** The name of the fixture family, for the rows. */
251
+ readonly fixture: string;
252
+ /** The index of the seed in the family, for the rows. */
253
+ readonly seed: number;
254
+ /** The curve strategies that are G1 by their design. The rule `curve-g1` examines them. */
255
+ readonly smooth: ReadonlySet<string>;
256
+ /** The curve strategies that read the normals of the plugs. The rule `curve-ends` examines their end tangents. */
257
+ readonly normals: ReadonlySet<string>;
258
+ /** The options of each curve strategy, by its name. A strategy without an entry gets its defaults. */
259
+ readonly curveOptions?: Readonly<Record<string, object>>;
260
+ }
261
+
262
+ /** One curve strategy on the routes of one result: the row of the suite and the drawn curves. */
263
+ export interface CurveRun {
264
+ readonly row: SuiteRow;
265
+ /** The segments of each curve, in the order of the routes that have two points or more. */
266
+ readonly drawn: readonly CurveSegment[][];
267
+ /** The edge of each curve of `drawn`. */
268
+ readonly edges: readonly string[];
269
+ }
270
+
271
+ /** This function draws the routes of a result with each curve strategy and measures the curves (C1, C2, C3, C6 and the curve rules). */
272
+ export function curveRuns(graph: Graph, result: Result, curves: readonly Strategy<"curve">[], options: CurveRunOptions): CurveRun[] {
273
+ const byId = nodeIndex(graph);
274
+ const edges = new Map(graph.edges.map((e) => [e.id, e]));
275
+ const space = curveSpace(
276
+ graph.nodes.map((n) => n.box),
277
+ 0.25,
278
+ );
279
+ const u = boardUnit(graph);
280
+ const routes: CurveRoute[] = [];
281
+ const ids: string[] = [];
282
+ for (const r of result.routes) {
283
+ const e = edges.get(r.edge);
284
+ const s = e && byId.get(e.source.node);
285
+ const t = e && byId.get(e.target.node);
286
+ if (!s || !t || r.points.length < 2) continue;
287
+ ids.push(r.edge);
288
+ routes.push({ points: r.points, startNormal: SIDE_NORMAL[nearestSide(s.box, r.points[0]!)], endNormal: SIDE_NORMAL[nearestSide(t.box, r.points[r.points.length - 1]!)] });
289
+ }
290
+ const ink = routes.reduce((sum, r) => sum + polylineLength(r.points), 0);
291
+ return curves.map((curve): CurveRun => {
292
+ const own = options.curveOptions?.[curve.name];
293
+ const t0 = now();
294
+ const drawn = routes.map((r) => curve.run({ points: r.points, startNormal: r.startNormal, endNormal: r.endNormal, space }, own));
295
+ const samples = drawn.map((d) => sampleCurve(d, 0.25));
296
+ const ms = now() - t0;
297
+ const t1 = now();
298
+ const g1: number[] = [];
299
+ const g2: number[] = [];
300
+ const energy: number[] = [];
301
+ const rules = { "curve-clear": 0, "curve-ends": 0, "curve-g1": 0, "curve-simple": 0 };
302
+ let length = 0;
303
+ drawn.forEach((segments, i) => {
304
+ const r = routes[i]!;
305
+ const s = samples[i]!;
306
+ g1.push(g1Defect(segments));
307
+ g2.push(g2Defect(segments, u));
308
+ energy.push(bendingEnergy(s, 0.25));
309
+ length += polylineLength(s);
310
+ const normals = options.normals.has(curve.name);
311
+ const f = curveFaults({
312
+ polyline: r.points,
313
+ segments,
314
+ samples: s,
315
+ space,
316
+ smooth: options.smooth.has(curve.name),
317
+ ...(normals ? { startNormal: r.startNormal, endNormal: r.endNormal } : {}),
318
+ });
319
+ if (f.clear) rules["curve-clear"]++;
320
+ if (f.ends) rules["curve-ends"]++;
321
+ if (f.g1) rules["curve-g1"]++;
322
+ if (f.simple) rules["curve-simple"]++;
323
+ });
324
+ const n = Math.max(1, drawn.length);
325
+ const values = {
326
+ "C1.max": g1.reduce((m, x) => Math.max(m, x), 0),
327
+ "C1.share": g1.filter((x) => x > 0.5).length / n,
328
+ "C2.median": quantile(g2, 0.5) || 0,
329
+ "C2.max": g2.reduce((m, x) => Math.max(m, x), 0),
330
+ "C3.median": quantile(energy, 0.5) || 0,
331
+ "C3.q95": quantile(energy, 0.95) || 0,
332
+ "C6.length": ink > 0 ? length / ink : 1,
333
+ ...rules,
334
+ };
335
+ const row: SuiteRow = {
336
+ step: "curve",
337
+ fixture: options.fixture,
338
+ seed: options.seed,
339
+ strategy: curve.name,
340
+ nodes: graph.nodes.length,
341
+ edges: graph.edges.length,
342
+ violations: 0,
343
+ ms,
344
+ measureMs: now() - t1,
345
+ extraMs: 0,
346
+ values,
347
+ };
348
+ return { row, drawn, edges: ids };
349
+ });
350
+ }
351
+
352
+ /** One grouping rule on one graph: the row of the suite, the tree of the rule and the result of `sections` on that tree. */
353
+ export interface GroupRun {
354
+ readonly row: SuiteRow;
355
+ readonly tree: Section;
356
+ /** The result of the router `sections` with the tree of the rule. A tree that `checkSections` refuses gives no result. */
357
+ readonly result?: Result;
358
+ }
359
+
360
+ /**
361
+ * This function groups one graph with one grouping rule, routes it with the router `sections` on that tree, and measures the levels.
362
+ * The grouping of the changed graph gives the group stability (G9). `options` are the options of the rule on `graph`.
363
+ */
364
+ export function groupRun(
365
+ graph: Graph,
366
+ strategy: Strategy<"group">,
367
+ options: object,
368
+ change: { readonly graph: Graph; readonly options: object },
369
+ at: { readonly fixture: string; readonly seed: number },
370
+ ): GroupRun {
371
+ const t0 = now();
372
+ const tree = strategy.run(graph, options);
373
+ const treeMs = now() - t0;
374
+ const base = { step: "group" as const, fixture: at.fixture, seed: at.seed, strategy: strategy.name, nodes: graph.nodes.length, edges: graph.edges.length };
375
+ // A tree that `checkSections` refuses is one violation. The router refuses it too, so the row has no measures.
376
+ try {
377
+ checkSections(tree, graph.nodes.length);
378
+ } catch {
379
+ return { row: { ...base, violations: 1, ms: treeMs, measureMs: 0, extraMs: 0, treeMs, values: {} }, tree };
380
+ }
381
+ const result = sectionsRouter({ name: "sections", group: strategy, groupOptions: options }).route(graph);
382
+ const ms = now() - t0;
383
+ const t1 = now();
384
+ // The hard rules of the levels (`counts-sum` and `link-ends`) are the violations of the step `group`.
385
+ const violations = checkLevels(graph, result.levels ?? []).length;
386
+ const values: Record<string, number> = { ...levelMeasures(graph, result), "Z3.through": sumFaults(measureLevelFaults(result)).through };
387
+ const measureMs = now() - t1;
388
+ const t2 = now();
389
+ values["G9.ari"] = groupStability(tree, strategy.run(change.graph, change.options), graph.nodes.length);
390
+ const extraMs = now() - t2;
391
+ return { row: { ...base, violations, ms, measureMs, extraMs, treeMs, values }, tree, result };
392
+ }
393
+
394
+ /**
395
+ * This function gives the options of a grouping rule on a fixture. The rule `keys` gets a key from the data of the edges:
396
+ * the collection of the first edge into the node. A node without such an edge is a group alone.
397
+ */
398
+ export function fixtureGroupOptions(strategy: string, graph: Graph): object {
399
+ if (strategy !== "keys") return {};
400
+ const key = new Map<string, string>();
401
+ for (const e of graph.edges) {
402
+ const c = e.data?.["collection"];
403
+ if (typeof c === "string" && !key.has(e.target.node)) key.set(e.target.node, c);
404
+ }
405
+ return { key: (n: Node) => key.get(n.id) };
406
+ }
407
+
408
+ /**
409
+ * This function starts the measure suite of MEAS1. The suite measures each strategy of each step on each seed of each fixture family.
410
+ * The step `router` keeps the rows of today in `bench` for the first seed, so the tables of the benchmark stay the same.
411
+ */
412
+ export function runSuite(options: SuiteOptions = {}): { report: SuiteReport; bench: BenchRow[] } {
413
+ const max = options.max ?? 1000;
414
+ const seeds = Math.max(1, Math.floor(options.seeds ?? 5));
415
+ const seedsMaxNodes = options.seedsMaxNodes ?? 1000;
416
+ const seedsOf = (nodes: number): number => (nodes > seedsMaxNodes ? 1 : seeds);
417
+ const steps = new Set<Step>(options.steps ?? STEPS);
418
+ const cellLimit = options.cellLimit ?? Infinity;
419
+ const repeat = options.repeat ?? 3;
420
+ const stabilityMax = options.stabilityMax ?? 1000;
421
+ const curveMax = options.curveMax ?? 1000;
422
+ const curveRouter = options.curveRouter ?? "corners";
423
+ const registry = options.registry ?? STRATEGIES;
424
+ const smooth = new Set(options.smoothCurves ?? ["rounded", "arc", "spline", "smooth", "clothoid", "highway"]);
425
+ const normals = new Set(options.normalCurves ?? ["spline"]);
426
+ const log = options.log ?? ((): void => {});
427
+ const fixtures = steps.has("router") || steps.has("group") ? (options.fixtures ?? FIXTURES).filter((f) => f.nodes <= max) : [];
428
+ const routers = steps.has("router") ? listStrategies(registry, "router").filter((s) => options.routers?.includes(s.name) ?? true) : [];
429
+ const curves = steps.has("curve") ? listStrategies(registry, "curve") : [];
430
+ const groups = steps.has("group") ? listStrategies(registry, "group") : [];
431
+ const rooms = listStrategies(registry, "room");
432
+ const rows: SuiteRow[] = [];
433
+ const bench: BenchRow[] = [];
434
+
435
+ for (const spec of fixtures) {
436
+ for (let k = 0; k < seedsOf(spec.nodes); k++) {
437
+ const seed = familySeed(spec, k);
438
+ const graph = makeFixture({ ...spec, seed });
439
+ const change = changeGraph(graph, seed);
440
+ log(`${spec.name} seed ${k}: ${graph.nodes.length} nodes, ${graph.edges.length} edges`);
441
+ const first = rows.length;
442
+ // The step `router`: the hard rules and the measures of today, then the new measures.
443
+ for (const strategy of routers) {
444
+ if (graph.nodes.length > (strategy.maxNodes ?? Infinity)) continue;
445
+ const router = asRouter(strategy);
446
+ const t0 = now();
447
+ const { row, result } = benchRun(router, spec.name, graph, k === 0 ? repeat : 1, cellLimit);
448
+ const ms = now() - t0;
449
+ if (k === 0) bench.push(row);
450
+ const t1 = now();
451
+ const values: Record<string, number> = routeMeasures(graph, result, row);
452
+ const measureMs = now() - t1;
453
+ let extraMs = 0;
454
+ if (graph.nodes.length <= stabilityMax) {
455
+ const t2 = now();
456
+ const s = routeStability(graph, result, router.route(change.graph), { seed });
457
+ extraMs = now() - t2;
458
+ values["Z10.mean"] = s.mean;
459
+ values["Z10.max"] = s.max;
460
+ values["Z10.moved"] = s.moved;
461
+ }
462
+ rows.push({
463
+ step: "router",
464
+ fixture: spec.name,
465
+ seed: k,
466
+ strategy: strategy.name,
467
+ nodes: row.nodes,
468
+ edges: row.edges,
469
+ violations: row.violations + (row.deterministic ? 0 : 1),
470
+ ms,
471
+ measureMs,
472
+ extraMs,
473
+ values,
474
+ });
475
+ if (strategy.name === curveRouter && curves.length > 0 && graph.nodes.length <= curveMax)
476
+ rows.push(...curveRuns(graph, result, curves, { fixture: spec.name, seed: k, smooth, normals }).map((c) => c.row));
477
+ }
478
+ // The step `group`: the router `sections` with each grouping rule, and the grouping of the changed graph (G9).
479
+ for (const strategy of groups) {
480
+ const opts = fixtureGroupOptions(strategy.name, graph);
481
+ const changed = { graph: change.graph, options: fixtureGroupOptions(strategy.name, change.graph) };
482
+ rows.push(groupRun(graph, strategy, opts, changed, { fixture: spec.name, seed: k }).row);
483
+ }
484
+ // The time of each row, in milliseconds: the step, the new measures and the extra routes.
485
+ log(
486
+ ` ${rows
487
+ .slice(first)
488
+ .map((r) => `${r.step}/${r.strategy} ${r.ms.toFixed(0)}+${r.measureMs.toFixed(0)}+${r.extraMs.toFixed(0)}`)
489
+ .join(", ")}`,
490
+ );
491
+ }
492
+ }
493
+
494
+ // The step `room`: the board with each gap open to the gutter only, then each room strategy.
495
+ for (const family of steps.has("room") ? (options.roomFamilies ?? ROOM_FAMILIES) : []) {
496
+ if (family.cols * family.rows > max) continue;
497
+ for (let k = 0; k < seedsOf(family.cols * family.rows); k++) {
498
+ const graph = packedGridBoard({ seed: 11 + 1009 * k, cols: family.cols, rows: family.rows, long: family.long });
499
+ log(`${family.name} seed ${k}: ${graph.nodes.length} nodes, ${graph.edges.length} edges`);
500
+ const links = roomLinks(graph);
501
+ const area0 = areaOf(graph.nodes);
502
+ const boards: { name: string; open: () => readonly Node[] }[] = [
503
+ { name: "gutter", open: () => makeRoom(graph.nodes, links, { gutter: 8, spacing: 0, method: "routes" }).items },
504
+ ...rooms.map((s) => ({ name: s.name, open: () => openGaps(graph.nodes, s.run({ items: graph.nodes, links }, { gutter: 8, spacing: 4 })) })),
505
+ ];
506
+ for (const b of boards) {
507
+ const t0 = now();
508
+ const nodes = b.open();
509
+ const m = measureBoard({ nodes, edges: graph.edges }, 4);
510
+ const ms = now() - t0;
511
+ const t1 = now();
512
+ const order = roomOrderFaults(graph.nodes, nodes);
513
+ const measureMs = now() - t1;
514
+ rows.push({
515
+ step: "room",
516
+ fixture: family.name,
517
+ seed: k,
518
+ strategy: b.name,
519
+ nodes: graph.nodes.length,
520
+ edges: graph.edges.length,
521
+ violations: 0,
522
+ ms,
523
+ measureMs,
524
+ extraMs: 0,
525
+ values: {
526
+ "M1.area": area0 > 0 ? areaOf(nodes) / area0 : 1,
527
+ "M4.laneThrough": m.laneThrough,
528
+ crossings: m.crossings,
529
+ through: m.faults.through,
530
+ "R6.worstDetour": m.faults.worstDetour,
531
+ "room-order": order,
532
+ },
533
+ });
534
+ }
535
+ }
536
+ }
537
+
538
+ const summaries = summarizeRows(rows);
539
+ return {
540
+ report: {
541
+ options: { max, seeds, seedsMaxNodes, repeat, stabilityMax, curveMax, curveRouter },
542
+ rows,
543
+ summaries,
544
+ fronts: suiteFronts(summaries),
545
+ rules: ruleCounts(rows),
546
+ },
547
+ bench,
548
+ };
549
+ }
550
+
551
+ /**
552
+ * This function puts the rows of the suite in the order of one run. It sorts them by fixture family, then by seed.
553
+ * In one seed, the routers come first, and the curves come after the router of the curves. The grouping rules and the room come last.
554
+ * A name that is not in its list goes after the names of the list.
555
+ * The workflow `Bench` uses it to join the reports of its parts.
556
+ */
557
+ export function orderSuiteRows(
558
+ rows: readonly SuiteRow[],
559
+ options: { registry?: Registry; fixtures?: readonly FixtureSpec[]; roomFamilies?: readonly RoomFamily[]; curveRouter?: string } = {},
560
+ ): SuiteRow[] {
561
+ const registry = options.registry ?? STRATEGIES;
562
+ const families = [...(options.fixtures ?? FIXTURES).map((f) => f.name), ...(options.roomFamilies ?? ROOM_FAMILIES).map((f) => f.name)];
563
+ const place = (list: readonly string[], name: string): number => {
564
+ const i = list.indexOf(name);
565
+ return i < 0 ? list.length : i;
566
+ };
567
+ const names = (step: Step): string[] => listStrategies(registry, step).map((s) => s.name);
568
+ const routers = names("router");
569
+ const curves = names("curve");
570
+ const after = place(routers, options.curveRouter ?? "corners");
571
+ // The routers and the curves share one rank. Each curve comes after the router of the curves, as `runSuite` gives them.
572
+ const rank = (r: SuiteRow): [number, number] => {
573
+ if (r.step === "router") return [0, 2 * place(routers, r.strategy) * (curves.length + 1)];
574
+ if (r.step === "curve") return [0, 2 * after * (curves.length + 1) + 1 + place(curves, r.strategy)];
575
+ if (r.step === "group") return [1, place(names("group"), r.strategy)];
576
+ return [2, r.strategy === "gutter" ? -1 : place(names("room"), r.strategy)];
577
+ };
578
+ return rows
579
+ .map((row, i) => ({ row, i, f: place(families, row.fixture), rank: rank(row) }))
580
+ .sort((a, b) => a.f - b.f || a.row.seed - b.row.seed || a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.i - b.i)
581
+ .map((x) => x.row);
582
+ }
583
+
584
+ /**
585
+ * This function joins the reports of the parts of one benchmark into one report. It puts the rows in the order of one run.
586
+ * Then it calculates the summaries, the fronts and the rule counts again from all the rows.
587
+ */
588
+ export function joinReports(reports: readonly SuiteReport[], options: { registry?: Registry; fixtures?: readonly FixtureSpec[]; roomFamilies?: readonly RoomFamily[] } = {}): SuiteReport {
589
+ const first = reports[0]?.options;
590
+ const rows = orderSuiteRows(
591
+ reports.flatMap((r) => r.rows),
592
+ { ...options, curveRouter: first?.curveRouter ?? "corners" },
593
+ );
594
+ const summaries = summarizeRows(rows);
595
+ return {
596
+ options: {
597
+ max: Math.max(0, ...reports.map((r) => r.options.max)),
598
+ seeds: first?.seeds ?? 0,
599
+ seedsMaxNodes: first?.seedsMaxNodes ?? 0,
600
+ repeat: first?.repeat ?? 0,
601
+ stabilityMax: first?.stabilityMax ?? 0,
602
+ curveMax: first?.curveMax ?? 0,
603
+ curveRouter: first?.curveRouter ?? "corners",
604
+ },
605
+ rows,
606
+ summaries,
607
+ fronts: suiteFronts(summaries),
608
+ rules: ruleCounts(rows),
609
+ };
610
+ }
611
+
612
+ /** This function gives the area of the bounding box of the nodes. */
613
+ function areaOf(nodes: readonly Node[]): number {
614
+ let x0 = Infinity;
615
+ let y0 = Infinity;
616
+ let x1 = -Infinity;
617
+ let y1 = -Infinity;
618
+ for (const n of nodes) {
619
+ x0 = Math.min(x0, n.box.x);
620
+ y0 = Math.min(y0, n.box.y);
621
+ x1 = Math.max(x1, n.box.x + n.box.w);
622
+ y1 = Math.max(y1, n.box.y + n.box.h);
623
+ }
624
+ return nodes.length ? (x1 - x0) * (y1 - y0) : 0;
625
+ }
626
+
627
+ /** This function gives the spread of each measure of each strategy on each fixture family, over the seeds. It keeps the order of the rows. */
628
+ export function summarizeRows(rows: readonly SuiteRow[]): SuiteSummary[] {
629
+ const groups = new Map<string, SuiteRow[]>();
630
+ for (const r of rows) {
631
+ const key = `${r.step}\u0000${r.fixture}\u0000${r.strategy}`;
632
+ const list = groups.get(key);
633
+ if (list) list.push(r);
634
+ else groups.set(key, [r]);
635
+ }
636
+ return [...groups.values()].map((list) => {
637
+ const first = list[0]!;
638
+ const keys = [...new Set(list.flatMap((r) => Object.keys(r.values)))];
639
+ const measures: Record<string, Spread> = {};
640
+ for (const k of keys) measures[k] = summarize(list.map((r) => r.values[k] ?? Number.NaN));
641
+ measures["ms"] = summarize(list.map((r) => r.ms));
642
+ measures["measureMs"] = summarize(list.map((r) => r.measureMs));
643
+ if (list.some((r) => r.treeMs !== undefined)) measures["treeMs"] = summarize(list.map((r) => r.treeMs ?? Number.NaN));
644
+ return {
645
+ step: first.step,
646
+ fixture: first.fixture,
647
+ strategy: first.strategy,
648
+ nodes: first.nodes,
649
+ seeds: list.length,
650
+ violations: list.reduce((s, r) => s + r.violations, 0),
651
+ measures,
652
+ };
653
+ });
654
+ }
655
+
656
+ /**
657
+ * This function gives the Pareto front of each step on each fixture family, over the medians of the seeds.
658
+ * A strategy with a violation of a hard rule does not enter the front, as MEAS0 asks.
659
+ */
660
+ export function suiteFronts(summaries: readonly SuiteSummary[], objectives: Readonly<Record<Step, readonly Objective[]>> = FRONT_OBJECTIVES): SuiteFront[] {
661
+ const groups = new Map<string, SuiteSummary[]>();
662
+ for (const s of summaries) {
663
+ const key = `${s.step}\u0000${s.fixture}`;
664
+ const list = groups.get(key);
665
+ if (list) list.push(s);
666
+ else groups.set(key, [s]);
667
+ }
668
+ return [...groups.values()].map((list) => {
669
+ const first = list[0]!;
670
+ const axes = objectives[first.step];
671
+ const fair = list.filter((s) => s.violations === 0);
672
+ const medians = (s: SuiteSummary): Record<string, number> => Object.fromEntries(axes.map((o) => [o.key, s.measures[o.key]?.median ?? Number.NaN]));
673
+ return {
674
+ step: first.step,
675
+ fixture: first.fixture,
676
+ nodes: first.nodes,
677
+ objectives: axes,
678
+ front: paretoFront(fair, medians, axes).map((s) => s.strategy),
679
+ excluded: list.filter((s) => s.violations > 0).map((s) => s.strategy),
680
+ strategies: list.map((s) => s.strategy),
681
+ };
682
+ });
683
+ }
684
+
685
+ /** This function sums each proposed hard rule of MEAS0 for each strategy of each step, over each fixture and each seed. */
686
+ export function ruleCounts(rows: readonly SuiteRow[]): RuleCount[] {
687
+ const out = new Map<string, RuleCount>();
688
+ for (const r of rows)
689
+ for (const rule of PROPOSED_RULES) {
690
+ const v = r.values[rule];
691
+ if (v === undefined) continue;
692
+ const key = `${r.step}\u0000${r.strategy}\u0000${rule}`;
693
+ const known = out.get(key);
694
+ out.set(key, { step: r.step, strategy: r.strategy, rule, count: (known?.count ?? 0) + v });
695
+ }
696
+ return [...out.values()];
697
+ }
698
+
699
+ const fmt = (v: number | undefined, d = 3): string => (v === undefined || Number.isNaN(v) ? "-" : Number.isInteger(v) ? String(v) : v.toFixed(d));
700
+
701
+ /** The columns of the tables of `formatSuite`: the keys of the measures of each step. */
702
+ export const SUITE_COLUMNS: Readonly<Record<Step, readonly string[]>> = {
703
+ router: ["R1.perEdge", "R2.angle", "R2.min", "B1.inkSaved", "R5.median", "R5.q95", "R4.turn", "R11.q05", "B4.ambiguity", "Z10.moved", "Z8.order", "Z7.position", "G8.treeCost"],
704
+ curve: ["C1.share", "C1.max", "C2.median", "C3.median", "C3.q95", "C6.length"],
705
+ room: ["M1.area", "M4.laneThrough", "crossings", "through", "room-order"],
706
+ group: ["Z4.farItems", "G1.modularity", "G2.cut", "G6.balance", "G8.treeCost", "G9.ari", "Z7.position", "Z8.order", "Z3.through", "treeMs"],
707
+ };
708
+
709
+ /**
710
+ * This function writes the summaries of one step as a Markdown table. Each cell is the median over the seeds, with the interquartile range in brackets.
711
+ * The column `R1 IQR/median` gives the spread of the crossings.
712
+ */
713
+ export function formatSummaries(summaries: readonly SuiteSummary[], step: Step, columns: readonly string[] = SUITE_COLUMNS[step]): string {
714
+ const extra = step === "router" ? ["R1 IQR/median"] : [];
715
+ const head = `| ${step} | fixture | seeds | violations | ${[...columns, ...extra].join(" | ")} |`;
716
+ const line = `|---|---|---:|---:|${[...columns, ...extra].map(() => "---:").join("|")}|`;
717
+ const body = summaries
718
+ .filter((s) => s.step === step)
719
+ .map((s) => {
720
+ const cells = columns.map((c) => {
721
+ const m = s.measures[c];
722
+ return m && m.n > 0 ? `${fmt(m.median)} (${fmt(m.iqr)})` : "-";
723
+ });
724
+ if (step === "router") {
725
+ const c = s.measures["R1.crossings"];
726
+ cells.push(c && c.median > 0 ? fmt(c.iqr / c.median) : "-");
727
+ }
728
+ return `| ${s.strategy} | ${s.fixture} | ${s.seeds} | ${s.violations} | ${cells.join(" | ")} |`;
729
+ });
730
+ return [head, line, ...body].join("\n");
731
+ }
732
+
733
+ /** This function writes the Pareto fronts as a Markdown table. */
734
+ export function formatFronts(fronts: readonly SuiteFront[]): string {
735
+ const head = "| step | fixture | nodes | axes | front | not on the front | excluded (hard rules) |";
736
+ const line = "|---|---|---:|---|---|---|---|";
737
+ const body = fronts.map((f) => {
738
+ const axes = f.objectives.map((o) => `${o.key} ${o.better === "min" ? "↓" : "↑"}`).join(", ");
739
+ const off = f.strategies.filter((s) => !f.front.includes(s) && !f.excluded.includes(s));
740
+ return `| ${f.step} | ${f.fixture} | ${f.nodes} | ${axes} | ${f.front.join(", ")} | ${off.join(", ") || "-"} | ${f.excluded.join(", ") || "-"} |`;
741
+ });
742
+ return [head, line, ...body].join("\n");
743
+ }
744
+
745
+ /** This function writes the counts of the proposed hard rules as a Markdown table: one row for each step and strategy. */
746
+ export function formatRules(rules: readonly RuleCount[]): string {
747
+ const head = `| step | strategy | ${PROPOSED_RULES.join(" | ")} |`;
748
+ const line = `|---|---|${PROPOSED_RULES.map(() => "---:").join("|")}|`;
749
+ const keys = [...new Set(rules.map((r) => `${r.step}\u0000${r.strategy}`))];
750
+ const body = keys.map((k) => {
751
+ const [step, strategy] = k.split("\u0000");
752
+ const cells = PROPOSED_RULES.map((rule) => {
753
+ const r = rules.find((x) => x.step === step && x.strategy === strategy && x.rule === rule);
754
+ return r ? String(r.count) : "-";
755
+ });
756
+ return `| ${step} | ${strategy} | ${cells.join(" | ")} |`;
757
+ });
758
+ return [head, line, ...body].join("\n");
759
+ }
760
+
761
+ /**
762
+ * This function compares the time of the new measures with the time of the step and the measures of today.
763
+ * It gives one row for each fixture family of a step. The extra routes of the stability measures have their own column.
764
+ */
765
+ export function formatMeasureTime(rows: readonly SuiteRow[], step: Step = "router"): string {
766
+ const head = "| step | fixture | nodes | rows | ms of today | ms of the new measures | share | ms of the extra runs |";
767
+ const line = "|---|---|---:|---:|---:|---:|---:|---:|";
768
+ const byFixture = new Map<string, SuiteRow[]>();
769
+ for (const r of rows.filter((x) => x.step === step)) {
770
+ const list = byFixture.get(r.fixture);
771
+ if (list) list.push(r);
772
+ else byFixture.set(r.fixture, [r]);
773
+ }
774
+ const body = [...byFixture].map(([fixture, list]) => {
775
+ const old = list.reduce((s, r) => s + r.ms, 0);
776
+ const added = list.reduce((s, r) => s + r.measureMs, 0);
777
+ const extra = list.reduce((s, r) => s + r.extraMs, 0);
778
+ return `| ${step} | ${fixture} | ${list[0]!.nodes} | ${list.length} | ${old.toFixed(0)} | ${added.toFixed(0)} | ${old > 0 ? ((100 * added) / old).toFixed(1) : "-"}% | ${extra.toFixed(0)} |`;
779
+ });
780
+ return [head, line, ...body].join("\n");
781
+ }