@mark1russell7/highways 0.3.0 → 0.4.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.
@@ -0,0 +1,6 @@
1
+ export * from "./hash.ts";
2
+ export * from "./forms.ts";
3
+ export * from "./measures.ts";
4
+ export * from "./set.ts";
5
+ export * from "./screen.ts";
6
+ export * from "./generators.ts";
@@ -0,0 +1,114 @@
1
+ import type { Objective } from "../measures/pareto.ts";
2
+
3
+ /**
4
+ * One measure of a record. `key` is the key of the measure in the values of `runSuite` and `compareStep`.
5
+ * `name` is the name of the measure in a record of the form `layout-record/1`.
6
+ */
7
+ export interface MeasureInfo {
8
+ /** The key in the values of `runSuite` and `compareStep`, for example `R5.median`. */
9
+ readonly key: string;
10
+ /** The id of the measure in `docs/research/MEASURES.md` (MEAS0), for example `R5`. */
11
+ readonly id: string;
12
+ /** The name in a record: lowercase, with a dot after the stage, for example `route.detour.median`. */
13
+ readonly name: string;
14
+ readonly unit: string;
15
+ readonly better: "lower" | "higher";
16
+ /**
17
+ * This value is `true` when the positions of the boxes alone can move the value, with the same router.
18
+ * Then two values compare only on the same input. The value is `false` when the measure scores the routes or the curves for a fixed placement.
19
+ */
20
+ readonly placementDependent: boolean;
21
+ }
22
+
23
+ /** The text of a row of `MEASURES`: the key, the id, the name, the unit, the better side and the placement dependence. */
24
+ type Row = readonly [key: string, id: string, name: string, unit: string, better: "lower" | "higher", placement: boolean];
25
+
26
+ const ROWS: readonly Row[] = [
27
+ // The routes. A placement makes crossings, plugs on short sides and boxes that touch, so these measures depend on it.
28
+ ["R1.crossings", "R1", "route.crossings", "count", "lower", true],
29
+ ["R1.perEdge", "R1", "route.crossings.per-edge", "count for each edge", "lower", true],
30
+ ["R2.angle", "R2", "route.crossing-angle.mean", "0 to 1", "higher", true],
31
+ ["R2.min", "R2", "route.crossing-angle.min", "degrees", "higher", true],
32
+ ["R3.bends", "R3", "route.bends", "count for each route", "lower", false],
33
+ ["R4.turn", "R4", "route.turn.mean", "half turns for each route", "lower", false],
34
+ ["R4.max", "R4", "route.turn.max", "degrees", "lower", false],
35
+ ["R5.median", "R5", "route.detour.median", "ratio", "lower", false],
36
+ ["R5.q95", "R5", "route.detour.q95", "ratio", "lower", false],
37
+ ["R5.mean", "R5", "route.detour.mean", "ratio", "lower", false],
38
+ ["R6.worstDetour", "R6", "route.detour.max", "ratio", "lower", false],
39
+ ["R7.backtrack", "R7", "route.backtrack", "0 to 1", "lower", false],
40
+ ["R8.clearance", "R8", "route.clearance.mean", "units", "higher", true],
41
+ ["R8.tight", "R8", "route.clearance.tight", "0 to 1", "lower", true],
42
+ ["R9.through", "R9", "route.through", "count", "lower", true],
43
+ ["R10.plugAngle", "R10", "route.plug-angle", "degrees", "lower", false],
44
+ ["R11.min", "R11", "route.plug-spacing.min", "0 to 1", "higher", true],
45
+ ["R11.q05", "R11", "route.plug-spacing.q05", "0 to 1", "higher", true],
46
+ ["R13.outside", "R13", "route.outside", "count", "lower", false],
47
+ ["R15.inflections", "R15", "route.inflections", "count for each route", "lower", false],
48
+ ["ink", "R1", "route.ink", "units", "lower", true],
49
+ // The bundles.
50
+ ["B1.inkSaved", "B1", "route.bundle.ink-saved", "0 to 1", "higher", false],
51
+ ["B4.ambiguity", "B4", "route.bundle.ambiguity", "0 to 1", "lower", false],
52
+ // The stability after the small change of `changeGraph`, and against the run on the base input of a variant.
53
+ ["Z10.mean", "Z10", "route.stability.mean", "units of the board", "lower", false],
54
+ ["Z10.max", "Z10", "route.stability.max", "units of the board", "lower", false],
55
+ ["Z10.moved", "Z10", "route.stability.moved", "0 to 1", "lower", false],
56
+ ["Z10.base.mean", "Z10", "route.stability.base.mean", "units of the board", "lower", false],
57
+ ["Z10.base.max", "Z10", "route.stability.base.max", "units of the board", "lower", false],
58
+ ["Z10.base.moved", "Z10", "route.stability.base.moved", "0 to 1", "lower", false],
59
+ // The zoom levels and the groups. The default grouping rule reads the positions, so most of these measures depend on the placement.
60
+ ["level-nesting", "Z6", "route.level.nesting", "count", "lower", false],
61
+ ["Z3.through", "Z3", "route.level.through", "count", "lower", true],
62
+ ["Z3.length", "Z3", "route.level.link-length", "units", "lower", true],
63
+ ["Z4.farItems", "Z4", "route.level.far-items", "count", "lower", true],
64
+ ["Z7.position", "Z7", "route.level.position", "no unit", "lower", true],
65
+ ["Z8.order", "Z8", "route.level.order", "-1 to 1", "higher", true],
66
+ ["G1.modularity", "G1", "route.group.modularity", "-0.5 to 1", "higher", true],
67
+ ["G2.cut", "G2", "route.group.cut", "0 to 1", "lower", true],
68
+ ["G6.balance", "G6", "route.group.balance", "0 to 1", "higher", true],
69
+ ["G8.treeCost", "G8", "route.group.tree-cost", "0 to 1", "lower", true],
70
+ ["G9.ari", "G9", "route.group.stability", "-1 to 1", "higher", true],
71
+ // The curves. A curve draws a fixed polyline, so no measure of a curve depends on the placement.
72
+ ["C1.max", "C1", "route.curve.c1.max", "degrees", "lower", false],
73
+ ["C1.share", "C1", "route.curve.c1.share", "0 to 1", "lower", false],
74
+ ["C2.median", "C2", "route.curve.c2.median", "no unit", "lower", false],
75
+ ["C2.max", "C2", "route.curve.c2.max", "no unit", "lower", false],
76
+ ["C3.median", "C3", "route.curve.c3.median", "no unit", "lower", false],
77
+ ["C3.q95", "C3", "route.curve.c3.q95", "no unit", "lower", false],
78
+ ["C5.peak", "C5", "route.curve.c5.peak", "curvature times the unit of the board", "lower", false],
79
+ ["C6.length", "C6", "route.curve.c6.length", "ratio", "lower", false],
80
+ ["curve-clear", "C7", "route.curve.rule.clear", "count", "lower", false],
81
+ ["curve-ends", "C10", "route.curve.rule.ends", "count", "lower", false],
82
+ ["curve-g1", "C1", "route.curve.rule.g1", "count", "lower", false],
83
+ ["curve-simple", "C9", "route.curve.rule.simple", "count", "lower", false],
84
+ ];
85
+
86
+ /**
87
+ * The measures of the records of the stage `route`, with the placement dependence of each one.
88
+ * A joint harness that moves the boxes and routes the edges reads `placementDependent`.
89
+ * It compares a measure with the value `true` only between runs on the same input.
90
+ */
91
+ export const MEASURES: readonly MeasureInfo[] = Object.freeze(ROWS.map(([key, id, name, unit, better, placementDependent]) => Object.freeze({ key, id, name, unit, better, placementDependent })));
92
+
93
+ const BY_KEY: ReadonlyMap<string, MeasureInfo> = new Map(MEASURES.map((m) => [m.key, m]));
94
+ const BY_NAME: ReadonlyMap<string, MeasureInfo> = new Map(MEASURES.map((m) => [m.name, m]));
95
+
96
+ /** This function gives the measure of a key of `runSuite` or `compareStep`, or `undefined` for a key that is not in `MEASURES`. */
97
+ export function measureByKey(key: string): MeasureInfo | undefined {
98
+ return BY_KEY.get(key);
99
+ }
100
+
101
+ /** This function gives the measure of a name of a record, or `undefined` for a name that is not in `MEASURES`. */
102
+ export function measureByName(name: string): MeasureInfo | undefined {
103
+ return BY_NAME.get(name);
104
+ }
105
+
106
+ /** This function gives the name of a key in a record. A key that is not in `MEASURES` gets the name `route.other.<key>`. */
107
+ export function measureName(key: string): string {
108
+ return BY_KEY.get(key)?.name ?? `route.other.${key.toLowerCase()}`;
109
+ }
110
+
111
+ /** This function gives the objectives of a Pareto front by the names of a record, from objectives by the keys of `runSuite`. */
112
+ export function namedObjectives(objectives: readonly Objective[]): Objective[] {
113
+ return objectives.map((o) => ({ key: measureName(o.key), better: o.better }));
114
+ }
@@ -0,0 +1,390 @@
1
+ import { highwayRouterStrategy } from "../../highways/router.ts";
2
+ import type { Graph, Result } from "../../model.ts";
3
+ import { STRATEGIES } from "../../strategies/builtins.ts";
4
+ import { createRegistry, getStrategy, listStrategies, STEPS, type AnyStrategy, type Registry, type Strategy } from "../../strategies/model.ts";
5
+ import { compareStep, type CompareRow, type CompareStep, type StepComparison } from "../compare.ts";
6
+ import { paretoFront, type Objective } from "../measures/pareto.ts";
7
+ import { routeStability } from "../measures/stability.ts";
8
+ import { quantile } from "../measures/stats.ts";
9
+ import { checkLevels, checkProperties, TOLERANCE } from "../properties.ts";
10
+ import { measureQuality } from "../quality.ts";
11
+ import { sameData } from "../run.ts";
12
+ import { FRONT_OBJECTIVES } from "../suite.ts";
13
+ import { inputGraph, jsonSafe, type LayoutInput, type LayoutPrediction, type LayoutRecord, type PredictionOp } from "./forms.ts";
14
+ import { measureByName, measureName, namedObjectives } from "./measures.ts";
15
+ import { SCREEN_V1, screenInputs, type ScreenSet } from "./set.ts";
16
+
17
+ /**
18
+ * The steps of the screen: the steps of `compareStep` and the step `highway`.
19
+ * The screen of the step `highway` routes the plan of each strategy with `follow`, and measures the routes as the routes of a router.
20
+ */
21
+ export type ScreenStep = CompareStep | "highway";
22
+
23
+ /** The steps of the screen, in the order of the pipeline. */
24
+ export const SCREEN_STEPS: readonly ScreenStep[] = ["group", "router", "highway", "curve"];
25
+
26
+ /** The routers that the screen of the step `highway` also runs by default. They are the baselines of the note on highways. */
27
+ export const HIGHWAY_BASE_ROUTERS: readonly string[] = ["corners", "shared", "trunks"];
28
+
29
+ /** The hard rules of the routes of a router, or of a plan after `follow`. */
30
+ const ROUTE_RULES: readonly string[] = ["complete", "ends-at-plugs", "fixed-plugs", "no-pass-through", "counts-sum", "link-ends", "bundle-trunk", "run-shared", "same-twice"];
31
+
32
+ /** The hard rules of each step of the screen, in the order of the records. A good run has 0 for each rule. */
33
+ export const SCREEN_RULES: Readonly<Record<ScreenStep, readonly string[]>> = {
34
+ router: ROUTE_RULES,
35
+ highway: ROUTE_RULES,
36
+ group: ["sections", "counts-sum", "link-ends", "same-twice"],
37
+ curve: ["same-twice"],
38
+ };
39
+
40
+ /** The options of `screen`. */
41
+ export interface ScreenOptions {
42
+ /** The screen set. The default is `SCREEN_V1`. */
43
+ readonly set?: ScreenSet;
44
+ /** The input files. The default is the files of `set`. */
45
+ readonly inputs?: readonly LayoutInput[];
46
+ /** The families of the inputs that the screen runs. The default is each family. */
47
+ readonly families?: readonly string[];
48
+ /**
49
+ * With `true`, the screen also runs each built-in strategy of the step, and the front compares the new strategies with them.
50
+ * With `false`, it runs only the strategies of the call. The default is `true`.
51
+ */
52
+ readonly baseline?: boolean;
53
+ /** The options of each strategy, by its name. A strategy without an entry gets its defaults. */
54
+ readonly options?: Readonly<Record<string, object>>;
55
+ /** The router whose routes the step `curve` draws. The default is `corners`. */
56
+ readonly curveRouter?: string;
57
+ /**
58
+ * The routers that the screen of the step `highway` also runs. Their records have the step `router`.
59
+ * The default is `HIGHWAY_BASE_ROUTERS`. The other steps do not read it.
60
+ */
61
+ readonly baseRouters?: readonly string[];
62
+ /** The seed of the small change of the stability measures. The default is 1. */
63
+ readonly seed?: number;
64
+ /** The commit of the code, for the records. */
65
+ readonly commit?: string;
66
+ /** The predictions of the run. Each record names the first prediction with its strategy, its set and its input. */
67
+ readonly predictions?: readonly LayoutPrediction[];
68
+ /** This function gets one line of progress for each input. */
69
+ readonly log?: (line: string) => void;
70
+ }
71
+
72
+ /** The summary of one strategy over the inputs of a screen. */
73
+ export interface ScreenStrategySummary {
74
+ readonly strategy: string;
75
+ /** This value is `true` for a strategy of the call, and `false` for a built-in strategy of the baseline. */
76
+ readonly candidate: boolean;
77
+ /** The number of inputs where the strategy ran. */
78
+ readonly runs: number;
79
+ /** The number of inputs that the strategy skipped. */
80
+ readonly skipped: number;
81
+ /** This value is `true` when each run gave the same output two times. */
82
+ readonly deterministic: boolean;
83
+ /** The sum of the breaks of each hard rule over the runs. */
84
+ readonly hard: Readonly<Record<string, number>>;
85
+ /** The sum of `hard`. */
86
+ readonly hardTotal: number;
87
+ /**
88
+ * The breaks over the floor of each input. The floor is the counts of the built-in strategy with the fewest breaks on that input.
89
+ * Each built-in breaks a rule on some boards, for example on a board where the boxes touch. Without built-ins, the floor is 0.
90
+ */
91
+ readonly hardOverFloor: number;
92
+ /** The median of each measure over the runs, by the names of a record. */
93
+ readonly medians: Readonly<Record<string, number>>;
94
+ /** The median time of a run, in milliseconds. */
95
+ readonly ms: number;
96
+ /** The number of inputs where the strategy is on the Pareto front of its input. */
97
+ readonly onFront: number;
98
+ }
99
+
100
+ /** A prediction with the measured value. */
101
+ export interface PredictionCheck extends LayoutPrediction {
102
+ readonly op: PredictionOp;
103
+ /** The measured value: the value of the record of the input, or the median over the set. It is absent when no record has the measure. */
104
+ readonly measured?: number;
105
+ /** This value is `true` when the measured value meets the prediction. It is absent when there is no measured value. */
106
+ readonly met?: boolean;
107
+ }
108
+
109
+ /** The summary of a screen. */
110
+ export interface ScreenSummary {
111
+ readonly set: string;
112
+ readonly step: ScreenStep;
113
+ /** The number of inputs. */
114
+ readonly inputs: number;
115
+ /** The axes of the Pareto front, by the names of a record. */
116
+ readonly objectives: readonly Objective[];
117
+ /** One summary for each strategy, in the order of the registry. */
118
+ readonly strategies: readonly ScreenStrategySummary[];
119
+ /** The strategies on the Pareto front of the medians over the set. */
120
+ readonly front: readonly string[];
121
+ /** The strategies with a break over the floor, without a run or with two different outputs of one input. They do not enter the front. */
122
+ readonly excluded: readonly string[];
123
+ readonly predictions: readonly PredictionCheck[];
124
+ }
125
+
126
+ /** The result of `screen`: one record for each strategy and input, and the summary. */
127
+ export interface ScreenReport {
128
+ readonly records: readonly LayoutRecord[];
129
+ readonly summary: ScreenSummary;
130
+ }
131
+
132
+ /** This function counts the violations of each rule, with 0 for each rule of `rules`. */
133
+ function countRules(rules: readonly string[], violations: readonly { readonly rule: string }[]): Record<string, number> {
134
+ const out: Record<string, number> = Object.fromEntries(rules.map((r) => [r, 0]));
135
+ for (const v of violations) out[v.rule] = (out[v.rule] ?? 0) + 1;
136
+ return out;
137
+ }
138
+
139
+ /** This function gives the median of a list of numbers, or `NaN` for an empty list. */
140
+ function median(values: readonly number[]): number {
141
+ return quantile(values, 0.5);
142
+ }
143
+
144
+ /** This function tells if a value meets a comparison with an expected value. */
145
+ function meets(value: number, op: PredictionOp, expected: number): boolean {
146
+ if (op === "<") return value < expected;
147
+ if (op === "<=") return value <= expected;
148
+ if (op === ">") return value > expected;
149
+ if (op === ">=") return value >= expected;
150
+ return value === expected;
151
+ }
152
+
153
+ /** This function makes the registry of a screen. It refuses a strategy of another step, and a new strategy with the name of a built-in strategy. */
154
+ function screenRegistry(step: ScreenStep, strategies: readonly (Strategy<ScreenStep> | string)[], baseline: boolean): { registry: Registry; candidates: Set<string> } {
155
+ const builtins = listStrategies(STRATEGIES, step) as readonly Strategy<ScreenStep>[];
156
+ const chosen: Strategy<ScreenStep>[] = [];
157
+ for (const s of strategies) {
158
+ if (typeof s === "string") {
159
+ const known = builtins.find((b) => b.name === s);
160
+ if (!known) throw new Error(`There is no ${step} "${s}". The registry has ${builtins.map((b) => b.name).join(", ")}.`);
161
+ chosen.push(known);
162
+ continue;
163
+ }
164
+ if (s.step !== step) throw new Error(`The strategy "${s.name}" is of the step "${s.step}", not "${step}".`);
165
+ const known = builtins.find((b) => b.name === s.name);
166
+ if (known && known !== s) throw new Error(`The new ${step} "${s.name}" has the name of a built-in strategy. Give it another name.`);
167
+ chosen.push(s);
168
+ }
169
+ const candidates = new Set(chosen.map((s) => s.name));
170
+ const others = STEPS.filter((x) => x !== step).flatMap((x) => listStrategies(STRATEGIES, x) as readonly AnyStrategy[]);
171
+ const own = baseline ? [...builtins, ...chosen.filter((s) => !builtins.includes(s))] : chosen.filter((s, i) => chosen.indexOf(s) === i);
172
+ return { registry: createRegistry([...others, ...(own as readonly AnyStrategy[])]), candidates };
173
+ }
174
+
175
+ /** This function tells if a step of the screen gives routes. The step `router` gives routes, and the step `highway` gives routes after `follow`. */
176
+ const givesRoutes = (step: ScreenStep): step is "router" | "highway" => step === "router" || step === "highway";
177
+
178
+ /** This function gives the values of a row of `compareStep`, with the measures of `measureQuality` for a router. */
179
+ function rowValues(step: ScreenStep, graph: Graph, row: CompareRow): Record<string, number> {
180
+ const values: Record<string, number> = { ...row.values };
181
+ delete values["ms"];
182
+ if (givesRoutes(step) && row.result) {
183
+ const q = measureQuality(graph, row.result);
184
+ values["R3.bends"] = q.bends;
185
+ values["R8.clearance"] = q.clearance;
186
+ values["R8.tight"] = q.tight;
187
+ values["R10.plugAngle"] = q.plugAngle;
188
+ values["R15.inflections"] = q.inflections;
189
+ }
190
+ return values;
191
+ }
192
+
193
+ /** This function gives the hard rules of a row of `compareStep`, and tells if the row gave the same output two times. */
194
+ function rowRules(step: ScreenStep, graph: Graph, row: CompareRow, again: CompareRow | undefined): { hard: Record<string, number>; deterministic: boolean } {
195
+ const rules = SCREEN_RULES[step];
196
+ if (givesRoutes(step)) {
197
+ const all = row.result ? checkProperties(graph, row.result, 1_000_000) : [];
198
+ // `compareStep` adds one violation when two routes of the input differ.
199
+ const deterministic = row.violations === all.length;
200
+ return { hard: { ...countRules(rules, all), "same-twice": deterministic ? 0 : 1 }, deterministic };
201
+ }
202
+ if (step === "group") {
203
+ const all = row.result ? checkLevels(graph, row.result.levels ?? [], 1_000_000) : [{ rule: "sections" }];
204
+ const deterministic = again !== undefined && sameData(row.tree, again.tree) && sameData(row.result, again.result);
205
+ return { hard: { ...countRules(rules, all), "same-twice": deterministic ? 0 : 1 }, deterministic };
206
+ }
207
+ const deterministic = again !== undefined && sameData(row.curves, again.curves);
208
+ return { hard: { ...countRules(rules, []), "same-twice": deterministic ? 0 : 1 }, deterministic };
209
+ }
210
+
211
+ /** This function keeps the finite values and gives them the names of a record. */
212
+ function named(values: Readonly<Record<string, number>>): Record<string, number> {
213
+ const out: Record<string, number> = {};
214
+ for (const [k, v] of Object.entries(values)) if (Number.isFinite(v)) out[measureName(k)] = v;
215
+ return out;
216
+ }
217
+
218
+ /**
219
+ * This function screens strategies of one step on the inputs of a screen set. It is the cheap test of a new idea before a full build.
220
+ * Each strategy runs on each input with `compareStep`, and each run gives one record of the form `layout-record/1`.
221
+ * A strategy can be a name of the registry or a strategy object, for example from a prototype file. The registry does not change.
222
+ *
223
+ * The step `router` routes each input two times for the rule "same twice". The steps `group` and `curve` run each input two times for the same rule.
224
+ *
225
+ * The step `highway` makes the plan of each strategy and routes it with `follow`, two times. It also runs the routers of `baseRouters`.
226
+ * An input with a `base` in the inputs also gets the measures `route.stability.base.*`: the routes on the variant against the routes on its base.
227
+ * The same inputs and the same options give the same records. Only the times change.
228
+ */
229
+ export function screen(step: ScreenStep, strategies: readonly (Strategy<ScreenStep> | string)[] = [], options: ScreenOptions = {}): ScreenReport {
230
+ const set = options.set ?? SCREEN_V1;
231
+ const screened = screenRegistry(step, strategies, options.baseline ?? true);
232
+ const candidates = screened.candidates;
233
+ // The step `highway` runs as the step `router`. Each highway strategy with `follow` is a router, after the base routers.
234
+ const base = step === "highway" ? (options.baseRouters ?? HIGHWAY_BASE_ROUTERS).map((name) => getStrategy(STRATEGIES, "router", name)) : [];
235
+ const registry =
236
+ step === "highway"
237
+ ? createRegistry([...base, ...listStrategies(screened.registry, "highway").map((s) => highwayRouterStrategy(s))] as readonly AnyStrategy[])
238
+ : screened.registry;
239
+ const compareAs: CompareStep = step === "highway" ? "router" : step;
240
+ const baseNames = new Set(base.map((s) => s.name));
241
+ const stepOf = (name: string): ScreenStep => (baseNames.has(name) ? "router" : step);
242
+ const own = options.options ?? {};
243
+ const seed = options.seed ?? 1;
244
+ const log = options.log ?? ((): void => {});
245
+ const families = options.families;
246
+ const inputs = (options.inputs ?? screenInputs(set)).filter((i) => families?.includes(i.family) ?? true);
247
+ const bases = new Set(inputs.flatMap((i) => (i.base !== undefined ? [i.base] : [])));
248
+ const byName = new Map(listStrategies(registry, compareAs).map((s) => [s.name, s as Strategy<CompareStep>]));
249
+ const records: (LayoutRecord & { measures: Record<string, number> })[] = [];
250
+ const comparisons: StepComparison[] = [];
251
+ const kept = new Map<string, { graph: Graph; results: Map<string, Result> }>();
252
+ const variants: { record: (typeof records)[number]; base: string; result: Result }[] = [];
253
+
254
+ for (const input of inputs) {
255
+ const graph = inputGraph(input);
256
+ const compare = { registry, options: own, seed, name: input.id, ...(options.curveRouter ? { curveRouter: options.curveRouter } : {}) };
257
+ const cmp = compareStep(compareAs, graph, compare);
258
+ const again = givesRoutes(step) ? undefined : compareStep(compareAs, graph, compare);
259
+ comparisons.push(cmp);
260
+ log(`${input.id}: ${graph.nodes.length} nodes, ${graph.edges.length} edges, ${cmp.rows.map((r) => `${r.strategy} ${r.ms.toFixed(0)}`).join(", ")}`);
261
+ if (bases.has(input.id)) kept.set(input.id, { graph, results: new Map(cmp.rows.flatMap((r) => (r.result ? [[r.strategy, r.result] as const] : []))) });
262
+ cmp.rows.forEach((row, i) => {
263
+ const strategy = byName.get(row.strategy)!;
264
+ const head = {
265
+ schema: "layout-record/1" as const,
266
+ stage: "route" as const,
267
+ step: stepOf(row.strategy),
268
+ strategy: row.strategy,
269
+ options: jsonSafe({ ...strategy.defaults, ...own[row.strategy] }) as Record<string, unknown>,
270
+ corpus: input.set,
271
+ input: input.id,
272
+ inputHash: input.hash,
273
+ set: input.set,
274
+ seed: input.seed,
275
+ nodes: graph.nodes.length,
276
+ edges: graph.edges.length,
277
+ };
278
+ const prediction = options.predictions?.find((p) => p.strategy === row.strategy && p.set === input.set && p.input === input.id)?.id;
279
+ const tail = { ...(prediction !== undefined ? { prediction } : {}), ...(options.commit !== undefined ? { commit: options.commit } : {}) };
280
+ if (row.skipped !== undefined) {
281
+ records.push({ ...head, measures: {}, hard: {}, tolerance: { through: TOLERANCE }, deterministic: true, ms: 0, ...tail, skipped: row.skipped });
282
+ return;
283
+ }
284
+ const { hard, deterministic } = rowRules(step, graph, row, again?.rows[i]);
285
+ const record = { ...head, measures: named(rowValues(step, graph, row)), hard, tolerance: { through: TOLERANCE }, deterministic, ms: row.ms, ...tail };
286
+ records.push(record);
287
+ if (givesRoutes(step) && input.base !== undefined && row.result) variants.push({ record, base: input.base, result: row.result });
288
+ });
289
+ }
290
+
291
+ // The stability against the base: the same strategy on the base input and on its variant.
292
+ for (const v of variants) {
293
+ const base = kept.get(v.base);
294
+ const before = base?.results.get(v.record.strategy);
295
+ if (!base || !before) continue;
296
+ const s = routeStability(base.graph, before, v.result, { seed });
297
+ Object.assign(v.record.measures, named({ "Z10.base.mean": s.mean, "Z10.base.max": s.max, "Z10.base.moved": s.moved }));
298
+ }
299
+
300
+ return { records, summary: summarizeScreen(records, comparisons, { set: set.id, step, candidates, ...(options.predictions ? { predictions: options.predictions } : {}) }) };
301
+ }
302
+
303
+ /** This function gives the summary of the records of a screen. */
304
+ function summarizeScreen(
305
+ records: readonly LayoutRecord[],
306
+ comparisons: readonly StepComparison[],
307
+ at: { set: string; step: ScreenStep; candidates: ReadonlySet<string>; predictions?: readonly LayoutPrediction[] },
308
+ ): ScreenSummary {
309
+ const objectives = namedObjectives(FRONT_OBJECTIVES[at.step]);
310
+ const names = [...new Set(records.map((r) => r.strategy))];
311
+ const total = (r: LayoutRecord): number => Object.values(r.hard).reduce((n, v) => n + v, 0);
312
+ const floor = new Map<string, Readonly<Record<string, number>>>();
313
+ const best = new Map<string, number>();
314
+ for (const r of records) {
315
+ if (r.skipped !== undefined || at.candidates.has(r.strategy)) continue;
316
+ if (total(r) >= (best.get(r.input) ?? Number.POSITIVE_INFINITY)) continue;
317
+ best.set(r.input, total(r));
318
+ floor.set(r.input, r.hard);
319
+ }
320
+ const overFloor = (r: LayoutRecord): number => Object.entries(r.hard).reduce((n, [k, v]) => n + Math.max(0, v - (floor.get(r.input)?.[k] ?? 0)), 0);
321
+ const strategies = names.map((strategy): ScreenStrategySummary => {
322
+ const all = records.filter((r) => r.strategy === strategy);
323
+ const ran = all.filter((r) => r.skipped === undefined);
324
+ const hard: Record<string, number> = {};
325
+ for (const r of ran) for (const [k, v] of Object.entries(r.hard)) hard[k] = (hard[k] ?? 0) + v;
326
+ const keys = [...new Set(ran.flatMap((r) => Object.keys(r.measures)))];
327
+ const medians = Object.fromEntries(keys.map((k) => [k, median(ran.flatMap((r) => (r.measures[k] === undefined ? [] : [r.measures[k]])))]));
328
+ return {
329
+ strategy,
330
+ candidate: at.candidates.has(strategy),
331
+ runs: ran.length,
332
+ skipped: all.length - ran.length,
333
+ deterministic: ran.every((r) => r.deterministic),
334
+ hard,
335
+ hardTotal: Object.values(hard).reduce((s, v) => s + v, 0),
336
+ hardOverFloor: ran.reduce((n, r) => n + overFloor(r), 0),
337
+ medians,
338
+ ms: median(ran.map((r) => r.ms)),
339
+ onFront: comparisons.filter((c) => c.front.includes(strategy)).length,
340
+ };
341
+ });
342
+ const fair = strategies.filter((s) => s.runs > 0 && s.hardOverFloor === 0 && s.deterministic);
343
+ const front = paretoFront(fair, (s) => Object.fromEntries(objectives.map((o) => [o.key, s.medians[o.key] ?? Number.NaN])), objectives).map((s) => s.strategy);
344
+ const predictions = (at.predictions ?? []).map((p): PredictionCheck => {
345
+ const op = p.op ?? (measureByName(p.measure)?.better === "higher" ? ">=" : "<=");
346
+ const mine = records.filter((r) => r.strategy === p.strategy && r.set === p.set && r.skipped === undefined && (p.input === undefined || r.input === p.input));
347
+ const values = mine.flatMap((r) => (r.measures[p.measure] === undefined ? [] : [r.measures[p.measure]!]));
348
+ const measured = values.length === 0 ? undefined : p.input !== undefined ? values[0]! : median(values);
349
+ return { ...p, op, ...(measured === undefined ? {} : { measured, met: meets(measured, op, p.expected) }) };
350
+ });
351
+ return {
352
+ set: at.set,
353
+ step: at.step,
354
+ inputs: comparisons.length,
355
+ objectives,
356
+ strategies,
357
+ front,
358
+ excluded: strategies.filter((s) => !fair.includes(s)).map((s) => s.strategy),
359
+ predictions,
360
+ };
361
+ }
362
+
363
+ const fmt = (v: number | undefined): string => (v === undefined || Number.isNaN(v) ? "-" : Number.isInteger(v) ? String(v) : v.toFixed(3));
364
+
365
+ /** This function writes the summary of a screen as Markdown: one row for each strategy, the front and the predictions. */
366
+ export function formatScreen(summary: ScreenSummary): string {
367
+ const axes = summary.objectives.map((o) => `${o.key} ${o.better === "min" ? "↓" : "↑"}`);
368
+ const lines = [
369
+ `## Screen of the step \`${summary.step}\` on \`${summary.set}\``,
370
+ "",
371
+ `${summary.inputs} inputs, ${summary.strategies.length} strategies. Each cell of a measure is the median over the inputs.`,
372
+ "The floor of an input is the counts of the hard rules of the built-in strategy with the fewest breaks on that input.",
373
+ "",
374
+ `| strategy | new | runs | skipped | hard | over the floor | same twice | on the front of an input | ms | ${axes.join(" | ")} |`,
375
+ `|---|---|---:|---:|---:|---:|---|---:|---:|${axes.map(() => "---:").join("|")}|`,
376
+ ...summary.strategies.map(
377
+ (s) =>
378
+ `| ${s.strategy} | ${s.candidate ? "yes" : ""} | ${s.runs} | ${s.skipped} | ${s.hardTotal} | ${s.hardOverFloor} | ${s.deterministic ? "yes" : "no"} | ${s.onFront} | ${fmt(s.ms)} | ${summary.objectives.map((o) => fmt(s.medians[o.key])).join(" | ")} |`,
379
+ ),
380
+ "",
381
+ `Pareto front over the medians: ${summary.front.join(", ") || "-"}.`,
382
+ `Not on the front because of a break over the floor, no run or two different outputs: ${summary.excluded.join(", ") || "-"}.`,
383
+ ];
384
+ if (summary.predictions.length > 0) {
385
+ lines.push("", "| prediction | strategy | input | measure | expected | measured | met | reason |", "|---|---|---|---|---:|---:|---|---|");
386
+ for (const p of summary.predictions)
387
+ lines.push(`| ${p.id} | ${p.strategy} | ${p.input ?? "median"} | ${p.measure} | ${p.op} ${fmt(p.expected)} | ${fmt(p.measured)} | ${p.met === undefined ? "-" : p.met ? "yes" : "no"} | ${p.reason} |`);
388
+ }
389
+ return lines.join("\n") + "\n";
390
+ }
@@ -0,0 +1,136 @@
1
+ import { hubChainBoard, manyGroupsBoard, packedGridBoard } from "../../cases.ts";
2
+ import { FIXTURES, makeFixture, type FixtureSpec } from "../../fixtures.ts";
3
+ import type { Graph } from "../../model.ts";
4
+ import { familySeed } from "../suite.ts";
5
+ import { graphInput, type LayoutCell, type LayoutGenerator, type LayoutInput } from "./forms.ts";
6
+
7
+ /** A family of boards of a screen set. The member `k` of the family has the seed `k`. */
8
+ export interface ScreenFamily {
9
+ readonly name: string;
10
+ /** The known hard case that the family shows, in a few words. */
11
+ readonly shows: string;
12
+ /** The number of members. */
13
+ readonly seeds: number;
14
+ /** This function makes the member `k`. The same `k` always gives the same graph. */
15
+ readonly make: (k: number) => Graph;
16
+ /** This function gives the generator of the member `k`: its name, version, parameters and seed. */
17
+ readonly generator: (k: number) => LayoutGenerator;
18
+ /** The cell of each member in the taxonomy of the corpus. */
19
+ readonly cell: LayoutCell;
20
+ }
21
+
22
+ /** A screen set: a fixed list of small boards with an id. A new board or a changed board gets a new id. */
23
+ export interface ScreenSet {
24
+ /** The id of the set, for example `screen-v1`. The records name it in `set` and `corpus`. */
25
+ readonly id: string;
26
+ readonly families: readonly ScreenFamily[];
27
+ }
28
+
29
+ const fixture = (name: string): FixtureSpec => FIXTURES.find((f) => f.name === name)!;
30
+ const DENSE: FixtureSpec = { name: "dense-groups", kind: "clustered", nodes: 150, edgesPerNode: 2, seed: 3 };
31
+ const PACKED = { cols: 12, rows: 8, long: 48 } as const;
32
+
33
+ /** This function gives a family of `makeFixture`. The member `k` has the seed `familySeed(spec, k)`, as in `runSuite`. */
34
+ function fixtureFamily(spec: FixtureSpec, shows: string, seeds: number, cell: LayoutCell): ScreenFamily {
35
+ const { name, seed: _seed, ...params } = spec;
36
+ return {
37
+ name,
38
+ shows,
39
+ seeds,
40
+ cell,
41
+ make: (k) => makeFixture({ ...spec, seed: familySeed(spec, k) }),
42
+ generator: (k) => ({ name: "fixture", version: 1, params, seed: familySeed(spec, k) }),
43
+ };
44
+ }
45
+
46
+ /** The cell dimensions that each family of `screen-v1` shares. */
47
+ const BASE = { containerSize: "freeAspect", containerShape: "rect", obstacles: "none", mode: "offline", groups: "none" } as const;
48
+
49
+ /**
50
+ * The screen set `screen-v1`: 14 boards of made-up data from 10 to 150 nodes. Each family shows a known hard case.
51
+ * The seed 0 of each family is the fixture or the case board of the benchmark. The seed `k` of a fixture family is `familySeed(spec, k)`, as in `runSuite`.
52
+ * The generators wrap the fixtures and the case boards without a change to their output.
53
+ * This set does not change. The tests compare it with the files in `corpus/screen-v1`.
54
+ */
55
+ export const SCREEN_V1: ScreenSet = {
56
+ id: "screen-v1",
57
+ families: [
58
+ fixtureFamily(fixture("tiny-uniform"), "A tiny board: the cost of a strategy at its start.", 3, { ...BASE, count: "10", edgeDensity: "medium", edgeShape: "random", ports: "free" }),
59
+ fixtureFamily(fixture("small-clustered"), "Clusters, and fixed plugs on 20% of the nodes.", 3, { ...BASE, count: "100", edgeDensity: "medium", edgeShape: "clustered", ports: "fixed" }),
60
+ fixtureFamily(DENSE, "Dense groups whose boxes overlap at the far view.", 3, { ...BASE, count: "100", edgeDensity: "medium", edgeShape: "clustered", ports: "free" }),
61
+ {
62
+ name: "hub-chain",
63
+ shows: "A chain of hubs: routes climb away from their targets.",
64
+ seeds: 1,
65
+ cell: { ...BASE, count: "100", edgeDensity: "medium", edgeShape: "hubChain", ports: "free" },
66
+ make: () => hubChainBoard(),
67
+ generator: () => ({ name: "case.hub-chain", version: 1, params: {}, seed: 3 }),
68
+ },
69
+ {
70
+ name: "many-groups",
71
+ shows: "Many groups: level links go around the board.",
72
+ seeds: 1,
73
+ cell: { ...BASE, count: "100", edgeDensity: "medium", edgeShape: "clustered", ports: "free" },
74
+ make: () => manyGroupsBoard(),
75
+ generator: () => ({ name: "case.many-groups", version: 1, params: {}, seed: 7 }),
76
+ },
77
+ {
78
+ name: "packed-grid",
79
+ shows: "A packed grid: boxes touch, with no channel for the routes.",
80
+ seeds: 3,
81
+ cell: { ...BASE, count: "100", edgeDensity: "medium", edgeShape: "longRange", ports: "free" },
82
+ make: (k) => packedGridBoard({ ...PACKED, seed: 11 + 1009 * k }),
83
+ generator: (k) => ({ name: "case.packed-grid", version: 1, params: PACKED, seed: 11 + 1009 * k }),
84
+ },
85
+ ],
86
+ };
87
+
88
+ /** The screen sets by id. */
89
+ export const SCREEN_SETS: Readonly<Record<string, ScreenSet>> = { [SCREEN_V1.id]: SCREEN_V1 };
90
+
91
+ /** This function gives the id of the member `seed` of a family of a set: `<set>/<family>/s<seed>`. */
92
+ export function inputId(set: string, family: string, seed: number): string {
93
+ return `${set}/${family}/s${seed}`;
94
+ }
95
+
96
+ /** This function gives the input files of a screen set, in the order of its families and seeds. Each file has its hash. */
97
+ export function screenInputs(set: ScreenSet = SCREEN_V1): LayoutInput[] {
98
+ return set.families.flatMap((f) =>
99
+ Array.from({ length: f.seeds }, (_, k) => graphInput(f.make(k), { id: inputId(set.id, f.name, k), set: set.id, family: f.name, seed: k, generator: f.generator(k), cell: f.cell })),
100
+ );
101
+ }
102
+
103
+ /** A spec of a manifest: one generator with its version and parameters, and the seeds that the corpus uses. */
104
+ export interface CorpusSpec {
105
+ readonly generator: string;
106
+ readonly version: number;
107
+ readonly params: Readonly<Record<string, unknown>>;
108
+ readonly seeds: readonly number[];
109
+ }
110
+
111
+ /** The manifest `corpus.json` of a corpus, of the form `layout-corpus/1`. */
112
+ export interface LayoutCorpus {
113
+ readonly schema: "layout-corpus/1";
114
+ readonly corpus: string;
115
+ readonly specs: readonly CorpusSpec[];
116
+ readonly inputs: readonly { readonly id: string; readonly hash: string; readonly cell: LayoutCell }[];
117
+ }
118
+
119
+ /** This function gives the manifest of a list of inputs. The inputs of one generator, version and parameters share one spec. */
120
+ export function corpusManifest(corpus: string, inputs: readonly LayoutInput[]): LayoutCorpus {
121
+ const specs = new Map<string, { generator: string; version: number; params: Readonly<Record<string, unknown>>; seeds: number[] }>();
122
+ for (const input of inputs) {
123
+ const g = input.generator;
124
+ if (!g) continue;
125
+ const key = JSON.stringify([g.name, g.version, g.params]);
126
+ const spec = specs.get(key) ?? { generator: g.name, version: g.version, params: g.params, seeds: [] };
127
+ if (!spec.seeds.includes(g.seed)) spec.seeds.push(g.seed);
128
+ specs.set(key, spec);
129
+ }
130
+ return {
131
+ schema: "layout-corpus/1",
132
+ corpus,
133
+ specs: [...specs.values()],
134
+ inputs: inputs.map((i) => ({ id: i.id, hash: i.hash, cell: i.cell ?? {} })),
135
+ };
136
+ }