@mark1russell7/highways 0.9.4 → 0.9.6

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,177 @@
1
+ import { inkSaved } from "../../bench/measures/bundles.ts";
2
+ import { detourSpread } from "../../bench/measures/routes.ts";
3
+ import { checkProperties } from "../../bench/properties.ts";
4
+ import { measureQuality } from "../../bench/quality.ts";
5
+ import { rng } from "../../fixtures.ts";
6
+ import type { Graph } from "../../model.ts";
7
+ import type { Strategy, WholeOutput } from "../../strategies/model.ts";
8
+ import { runPipeline } from "../pipeline/index.ts";
9
+
10
+ /** The search of the whole strategy `fireflies`: the firefly algorithm, or the artificial bee colony. */
11
+ export type SwarmSearch = "firefly" | "bees";
12
+
13
+ /** The values of `SwarmSearch`. */
14
+ export const SWARM_SEARCHES: readonly SwarmSearch[] = Object.freeze(["firefly", "bees"]);
15
+
16
+ /** The options of the whole strategy `fireflies` (NAT-047). */
17
+ export interface FireflyOptions {
18
+ /** The search. The default is `firefly`. */
19
+ readonly search: SwarmSearch;
20
+ /** The budget: the largest number of different settings that the search routes, from 4 to 40. The default is 12. */
21
+ readonly budget: number;
22
+ /** The number of fireflies or food sources, from 4 to 20. The default is 6. */
23
+ readonly colony: number;
24
+ /** The absorption `gamma` of the light of a firefly, from 0.01 to 10. The default is 1. */
25
+ readonly gamma: number;
26
+ /** The size `alpha` of the random step of a firefly, as a share of the range, from 0.01 to 0.5. The default is 0.2. */
27
+ readonly alpha: number;
28
+ /** The number of tries with no gain, after which a scout bee leaves a food source, from 10 to 100. The default is 10. */
29
+ readonly limit: number;
30
+ /** The seed of the search, a whole number from 0. The same seed gives the same routes. The default is 1. */
31
+ readonly seed: number;
32
+ }
33
+
34
+ /** The default options of the strategy `fireflies`. */
35
+ export const FIREFLY_DEFAULTS: Readonly<FireflyOptions> = Object.freeze({ search: "firefly", budget: 12, colony: 6, gamma: 1, alpha: 0.2, limit: 10, seed: 1 });
36
+
37
+ /** The attraction of a firefly at the distance 0, as the record gives it. */
38
+ const BETA0 = 1;
39
+
40
+ /** The grid of the option `discount` of the router `shared`: 0.05 to 1 in steps of 0.05. */
41
+ const DISCOUNTS = 20;
42
+
43
+ /** The grid of the option `margin` of the router `shared`: 3 to 10 in steps of 1. */
44
+ const MARGINS = 8;
45
+
46
+ /** The largest number of rounds of a search. A round with no new setting still counts. */
47
+ const ROUNDS = 60;
48
+
49
+ /**
50
+ * This function gives the attraction of a firefly at the distance `r`: `BETA0 / (1 + u + u² / 2)` with `u = gamma × r²`.
51
+ * It is a rational stand-in for `BETA0 × exp(-u)`, with only `+`, `*` and `/`, so it is exact to the last bit on each platform.
52
+ */
53
+ export function attraction(gamma: number, r2: number): number {
54
+ const u = gamma * r2;
55
+ return BETA0 / (1 + u + (u * u) / 2);
56
+ }
57
+
58
+ /** One setting of the router `shared`: the option `discount` and the option `margin`. */
59
+ export interface SwarmSetting {
60
+ readonly discount: number;
61
+ readonly margin: number;
62
+ }
63
+
64
+ /** This function gives the setting of a point of the unit square, on the grid of the search. */
65
+ export function settingAt(x: number, y: number): SwarmSetting {
66
+ const i = Math.max(0, Math.min(DISCOUNTS - 1, Math.round(x * (DISCOUNTS - 1))));
67
+ const j = Math.max(0, Math.min(MARGINS - 1, Math.round(y * (MARGINS - 1))));
68
+ return { discount: (i + 1) / 20, margin: 3 + j };
69
+ }
70
+
71
+ /** The point of the unit square of the defaults of `shared`: the discount 0.5 and the margin 5. */
72
+ const HOME: readonly [number, number] = [9 / (DISCOUNTS - 1), 2 / (MARGINS - 1)];
73
+
74
+ /** One setting that the search routed: its score and its output. A setting with a break of a hard rule has the score `Infinity`. */
75
+ export interface SwarmEntry {
76
+ readonly setting: SwarmSetting;
77
+ readonly score: number;
78
+ readonly output: WholeOutput;
79
+ }
80
+
81
+ /** The result of `fireflies`: the output, the best setting and each setting that the search routed, in order. */
82
+ export interface SwarmRun {
83
+ readonly output: WholeOutput;
84
+ readonly best: SwarmSetting;
85
+ readonly entries: readonly SwarmEntry[];
86
+ }
87
+
88
+ /**
89
+ * This function gives the score of an output on its board. A lower score is better.
90
+ * The score is `crossings / edges + (median detour - 1) - ink saved`. A break of a hard rule gives `Infinity`.
91
+ */
92
+ export function swarmScore(output: WholeOutput): number {
93
+ if (checkProperties(output.graph, output.result, 1).length > 0) return Infinity;
94
+ const edges = Math.max(1, output.result.routes.length);
95
+ return measureQuality(output.graph, output.result).crossings / edges + (detourSpread(output.result.routes).median - 1) - inkSaved(output.result.routes);
96
+ }
97
+
98
+ /**
99
+ * This function runs the whole strategy `fireflies` (NAT-047, after the firefly algorithm of Yang 2008 and the bee colony of Karaboga 2005).
100
+ * It searches the options `discount` and `margin` of the router `router` on the board, on a grid of 20 × 8 settings.
101
+ * The first firefly or food source is the default setting, so the score of the output is not worse than the score of the default.
102
+ * A firefly moves toward each brighter one by `attraction(gamma, r²)` of the distance, plus a seeded step of size `alpha`.
103
+ * A bee tries `v = x + phi (x - x_k)` in one dimension, and a scout takes a new seeded place after `limit` tries with no gain.
104
+ * The search stops after `budget` different settings, or after 60 rounds. It reads only the board and the outputs of its own settings.
105
+ */
106
+ export function fireflies(graph: Graph, router: Strategy<"router">, options: Partial<FireflyOptions> = {}): SwarmRun {
107
+ const o = { ...FIREFLY_DEFAULTS, ...options };
108
+ const budget = Math.max(1, Math.floor(o.budget));
109
+ const random = rng(Math.max(0, Math.floor(o.seed)));
110
+ const known = new Map<string, SwarmEntry>();
111
+ const entries: SwarmEntry[] = [];
112
+ const score = (x: number, y: number): number => {
113
+ const setting = settingAt(x, y);
114
+ const key = `${setting.discount}:${setting.margin}`;
115
+ const seen = known.get(key);
116
+ if (seen) return seen.score;
117
+ if (entries.length >= budget) return Infinity;
118
+ const output = runPipeline(graph, { router, routerOptions: { ...setting } });
119
+ const entry = { setting, score: swarmScore(output), output };
120
+ known.set(key, entry);
121
+ entries.push(entry);
122
+ return entry.score;
123
+ };
124
+ const clamp = (v: number): number => Math.max(0, Math.min(1, v));
125
+ const colony = Math.max(2, Math.min(20, Math.floor(o.colony)));
126
+ const xs: [number, number][] = [[HOME[0], HOME[1]]];
127
+ while (xs.length < colony) xs.push([random(), random()]);
128
+ const fs = xs.map(([x, y]) => score(x, y));
129
+ if (o.search === "firefly") {
130
+ for (let round = 0; round < ROUNDS && entries.length < budget; round++) {
131
+ for (let i = 0; i < colony; i++) {
132
+ for (let j = 0; j < colony; j++) {
133
+ if (!(fs[j]! < fs[i]!)) continue;
134
+ const dx = xs[j]![0] - xs[i]![0];
135
+ const dy = xs[j]![1] - xs[i]![1];
136
+ const b = attraction(o.gamma, dx * dx + dy * dy);
137
+ xs[i] = [clamp(xs[i]![0] + b * dx + o.alpha * (random() - 0.5)), clamp(xs[i]![1] + b * dy + o.alpha * (random() - 0.5))];
138
+ fs[i] = score(xs[i]![0], xs[i]![1]);
139
+ }
140
+ }
141
+ }
142
+ } else {
143
+ const trials = new Array<number>(colony).fill(0);
144
+ const tryNear = (i: number): void => {
145
+ let k = Math.floor(random() * (colony - 1));
146
+ if (k >= i) k++;
147
+ const d = random() < 0.5 ? 0 : 1;
148
+ const phi = 2 * random() - 1;
149
+ const v: [number, number] = [xs[i]![0], xs[i]![1]];
150
+ v[d] = clamp(v[d] + phi * (v[d] - xs[k]![d]));
151
+ const f = score(v[0], v[1]);
152
+ if (f < fs[i]!) {
153
+ xs[i] = v;
154
+ fs[i] = f;
155
+ trials[i] = 0;
156
+ } else trials[i]!++;
157
+ };
158
+ for (let round = 0; round < ROUNDS && entries.length < budget; round++) {
159
+ for (let i = 0; i < colony; i++) tryNear(i);
160
+ // The onlooker bees go to the sources by rank: the best source gets the most visits.
161
+ const order = xs.map((_, i) => i).sort((a, b) => fs[a]! - fs[b]! || a - b);
162
+ for (let r = 0; r < colony; r++) {
163
+ const pick = order[Math.min(colony - 1, Math.floor(random() * random() * colony))]!;
164
+ tryNear(pick);
165
+ }
166
+ const worn = trials.findIndex((t, i) => t > o.limit && fs[i]! !== Math.min(...fs));
167
+ if (worn >= 0) {
168
+ xs[worn] = [random(), random()];
169
+ fs[worn] = score(xs[worn]![0], xs[worn]![1]);
170
+ trials[worn] = 0;
171
+ }
172
+ }
173
+ }
174
+ let best = entries[0]!;
175
+ for (const e of entries) if (e.score < best.score - 1e-12) best = e;
176
+ return { output: best.output, best: best.setting, entries };
177
+ }
@@ -0,0 +1,247 @@
1
+ import { checkProperties } from "../../bench/properties.ts";
2
+ import { rng } from "../../fixtures.ts";
3
+ import { dist, polylineLength, type Point } from "../../geom.ts";
4
+ import type { Graph, Route } from "../../model.ts";
5
+ import { turnsOf } from "../../routers/reroute/index.ts";
6
+ import type { WholeOutput } from "../../strategies/model.ts";
7
+ import { pairCosts } from "../curriculum/index.ts";
8
+ import { runPipeline } from "../pipeline/index.ts";
9
+ import type { PortfolioSetting } from "../portfolio/index.ts";
10
+
11
+ /** The features of one route, in order: crossings (R1), angle cost (R2), lane overlap (R12) over 20, detour minus 1 (R5), and bends over 2. */
12
+ export const PREFERENCE_FEATURES: readonly string[] = Object.freeze(["R1", "R2", "R12", "R5", "bends"]);
13
+
14
+ /** The scale of the lane overlap in the features, in units of length. */
15
+ const OVERLAP_SCALE = 20;
16
+
17
+ /**
18
+ * The weights of the made-up panel that labels the made-up pairs, in the order of `PREFERENCE_FEATURES`.
19
+ * The order comes from the studies of the readers of graphs: the crossings count most, then the bends and the length, then the angle (Purchase 1997, Ware, Purchase, Colpoys and McGill 2002).
20
+ * The numbers are made up. They do not come from the screen or from a board.
21
+ */
22
+ export const PREFERENCE_PANEL: readonly number[] = Object.freeze([3, 0.75, 0.5, 1.5, 1]);
23
+
24
+ /** The options of the fit of the preferences. */
25
+ export interface PreferenceFit {
26
+ /** The weight of the L2 term of the fit, from 0 to 1. The default is 0.01. */
27
+ readonly l2: number;
28
+ /** The seed of the made-up pairs. The default is 1. */
29
+ readonly seed: number;
30
+ /** The number of made-up pairs, from 50 to 1000. The default is 200. */
31
+ readonly pairs: number;
32
+ }
33
+
34
+ /** The options of the whole strategy `preferences` (RULE-038). */
35
+ export interface PreferenceOptions {
36
+ /** The weight of the L2 term of the fit, from 0 to 1. The default is 0.01. */
37
+ readonly l2: number;
38
+ /** The number of passes over the edges, from 1 to 3. The default is 1. */
39
+ readonly passes: number;
40
+ /** The number of settings in the pool of routes, from the start of its list, from 2 to 3. The default is 3. */
41
+ readonly settings: number;
42
+ /** A new route has a detour of at most this value, or of its old route, from 1.5 to 4. The default is 2. */
43
+ readonly detour: number;
44
+ }
45
+
46
+ /** The default options of the strategy `preferences`. */
47
+ export const PREFERENCE_DEFAULTS: Readonly<PreferenceOptions> = Object.freeze({ l2: 0.01, passes: 1, settings: 3, detour: 2 });
48
+
49
+ /** One made-up pair: the features of two routes, and 1 when the panel prefers the first one. */
50
+ export interface PreferencePair {
51
+ readonly a: readonly number[];
52
+ readonly b: readonly number[];
53
+ readonly first: 0 | 1;
54
+ }
55
+
56
+ /** The result of the fit: the weights, the agreement with held-out pairs, and the source of the pairs. */
57
+ export interface PreferenceModel {
58
+ readonly weights: readonly number[];
59
+ /** The share of 50 held-out made-up pairs where the weights agree with the panel. */
60
+ readonly agreement: number;
61
+ readonly source: string;
62
+ }
63
+
64
+ /**
65
+ * This function gives a smooth step from 0 to 1, a stand-in for the logistic function with only `+`, `*`, `/` and `Math.abs`.
66
+ * It is `0.5 + 0.5 z / (1 + |z|)`. These operations are exact to the last bit on each platform.
67
+ */
68
+ export function softStep(z: number): number {
69
+ return 0.5 + (0.5 * z) / (1 + Math.abs(z));
70
+ }
71
+
72
+ /** This function gives a made-up feature vector of one route. */
73
+ function madeUpFeatures(random: () => number): number[] {
74
+ return [Math.floor(random() * 5), random() * 2, random() * 2, random(), Math.floor(random() * 7) / 2];
75
+ }
76
+
77
+ /** This function gives a noise with a mean of 0 and a spread of approximately 0.6: the sum of three uniform values. */
78
+ function noise(random: () => number): number {
79
+ return (random() + random() + random() - 1.5) * 1.2;
80
+ }
81
+
82
+ /** This function gives the utility of a feature vector for the weights `w`. A larger utility is better. */
83
+ function utility(w: readonly number[], f: readonly number[]): number {
84
+ let u = 0;
85
+ for (let i = 0; i < f.length; i++) u -= w[i]! * f[i]!;
86
+ return u;
87
+ }
88
+
89
+ /**
90
+ * This function gives `count` made-up pairs, with the labels of the made-up panel `PREFERENCE_PANEL` and a noise.
91
+ * The pairs come from the seed alone. They do not come from a board.
92
+ */
93
+ export function madeUpPairs(count: number, seed: number): PreferencePair[] {
94
+ const random = rng(seed);
95
+ const out: PreferencePair[] = [];
96
+ for (let i = 0; i < count; i++) {
97
+ const a = madeUpFeatures(random);
98
+ const b = madeUpFeatures(random);
99
+ out.push({ a, b, first: utility(PREFERENCE_PANEL, a) - utility(PREFERENCE_PANEL, b) + noise(random) > 0 ? 1 : 0 });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** This function gives the share of the pairs where the weights `w` prefer the same route as the panel. */
105
+ export function agreementOf(w: readonly number[], pairs: readonly PreferencePair[]): number {
106
+ if (pairs.length === 0) return 0;
107
+ let n = 0;
108
+ for (const p of pairs) if ((utility(w, p.a) > utility(w, p.b) ? 1 : 0) === p.first) n++;
109
+ return n / pairs.length;
110
+ }
111
+
112
+ /** The fits of earlier calls, by their options. */
113
+ const FITS = new Map<string, PreferenceModel>();
114
+
115
+ /**
116
+ * This function fits the weights of the rules to made-up pairs (RULE-038, a Bradley-Terry model, after Bradley and Terry 1952).
117
+ * The probability that a reader prefers `a` to `b` is `softStep(u(a) - u(b))`, with `u(x) = -w · f(x)`. The fit takes 400 steps of the gradient of the score.
118
+ * An L2 term `l2 × |w|²` keeps the weights small. A weight below 0 becomes 0. The weights are scaled so that their sum is the number of features.
119
+ * The held-out pairs come from the seed plus 7919. The pairs do not come from the screen or from a board.
120
+ */
121
+ export function fitPreferences(options: Partial<PreferenceFit> = {}): PreferenceModel {
122
+ const o = { l2: 0.01, seed: 1, pairs: 200, ...options };
123
+ const key = JSON.stringify(o);
124
+ const known = FITS.get(key);
125
+ if (known) return known;
126
+ const pairs = madeUpPairs(Math.max(1, Math.floor(o.pairs)), o.seed);
127
+ const n = PREFERENCE_FEATURES.length;
128
+ const w = new Array<number>(n).fill(1);
129
+ const rate = 0.5;
130
+ for (let step = 0; step < 400; step++) {
131
+ const grad = new Array<number>(n).fill(0);
132
+ for (const p of pairs) {
133
+ const d = p.b.map((v, i) => v - p.a[i]!);
134
+ let z = 0;
135
+ for (let i = 0; i < n; i++) z += w[i]! * d[i]!;
136
+ const e = softStep(z) - p.first;
137
+ for (let i = 0; i < n; i++) grad[i]! += (e * d[i]!) / pairs.length;
138
+ }
139
+ for (let i = 0; i < n; i++) w[i] = Math.max(0, w[i]! - rate * (grad[i]! + 2 * o.l2 * w[i]!));
140
+ }
141
+ const sum = w.reduce((s, v) => s + v, 0);
142
+ const weights = sum > 1e-12 ? w.map((v) => (v * n) / sum) : w.map(() => 1);
143
+ const model: PreferenceModel = {
144
+ weights,
145
+ agreement: agreementOf(weights, madeUpPairs(50, o.seed + 7919)),
146
+ source: `${Math.floor(o.pairs)} made-up pairs of seed ${o.seed}, labelled by the made-up panel PREFERENCE_PANEL with a noise`,
147
+ };
148
+ FITS.set(key, model);
149
+ return model;
150
+ }
151
+
152
+ /** A route with its bounding box, for the quick test of two routes. */
153
+ interface Shape {
154
+ readonly route: Route;
155
+ readonly x0: number;
156
+ readonly y0: number;
157
+ readonly x1: number;
158
+ readonly y1: number;
159
+ /** The weighted cost of the route alone: its detour and its bends. */
160
+ readonly own: number;
161
+ }
162
+
163
+ /** This function gives the detour of a route: its length over the distance between its ends. */
164
+ function detourOf(points: readonly Point[]): number {
165
+ const span = dist(points[0]!, points[points.length - 1]!);
166
+ return span > 1e-9 ? polylineLength(points) / span : 1;
167
+ }
168
+
169
+ /**
170
+ * This function runs the whole strategy `preferences` (RULE-038). It fits the weights of the rules to made-up pairs (`fitPreferences`).
171
+ * Each edge can take its route from any setting of the pool. The first answer is the first setting.
172
+ * A pass visits the edges in order. An edge takes the route of the pool with the least weighted cost, when that cost is lower than the cost of its route.
173
+ * The cost is the weighted sum of the features of the route against the routes of the other edges. A route with a break of a hard rule is not in the pool.
174
+ * The output has the routes alone, with no runs, bundles or levels.
175
+ */
176
+ export function preferences(graph: Graph, list: readonly PortfolioSetting[], options: Partial<PreferenceOptions> = {}): WholeOutput {
177
+ const o = { ...PREFERENCE_DEFAULTS, ...options };
178
+ const w = fitPreferences({ l2: o.l2 }).weights;
179
+ const count = Math.max(1, Math.min(list.length, Math.floor(o.settings)));
180
+ const pool = new Map<string, Route[]>();
181
+ let first: WholeOutput | undefined;
182
+ for (const s of list.slice(0, count)) {
183
+ const limit = (s.pipeline.highway ?? s.pipeline.router).maxNodes;
184
+ if (limit !== undefined && graph.nodes.length > limit) continue;
185
+ const output = runPipeline(graph, s.pipeline);
186
+ first ??= output;
187
+ const faulty = new Set(checkProperties(output.graph, output.result, 1_000_000).map((v) => v.edge));
188
+ for (const r of output.result.routes) {
189
+ if (faulty.has(r.edge) || r.points.length < 2) continue;
190
+ const own = pool.get(r.edge);
191
+ if (own) own.push(r);
192
+ else pool.set(r.edge, [r]);
193
+ }
194
+ }
195
+ if (!first) return runPipeline(graph, list[0]!.pipeline);
196
+ const shapeOf = (route: Route): Shape => {
197
+ let x0 = Infinity;
198
+ let y0 = Infinity;
199
+ let x1 = -Infinity;
200
+ let y1 = -Infinity;
201
+ for (const p of route.points) {
202
+ x0 = Math.min(x0, p.x);
203
+ y0 = Math.min(y0, p.y);
204
+ x1 = Math.max(x1, p.x);
205
+ y1 = Math.max(y1, p.y);
206
+ }
207
+ return { route, x0, y0, x1, y1, own: w[3]! * (detourOf(route.points) - 1) + (w[4]! * turnsOf(route.points)) / 2 };
208
+ };
209
+ const current = new Map<string, Shape>(first.result.routes.map((r) => [r.edge, shapeOf(r)]));
210
+ for (const [edge, shape] of current) {
211
+ const own = pool.get(edge);
212
+ if (own && !own.includes(shape.route)) current.set(edge, shapeOf(own[0]!));
213
+ }
214
+ const near = 4;
215
+ const costOf = (edge: string, s: Shape): number => {
216
+ let sum = s.own;
217
+ for (const [other, t] of current) {
218
+ if (other === edge || t.x0 > s.x1 + near || t.x1 < s.x0 - near || t.y0 > s.y1 + near || t.y1 < s.y0 - near) continue;
219
+ const c = pairCosts(s.route.points, t.route.points);
220
+ sum += w[0]! * c.R1 + w[1]! * c.R2 + (w[2]! * c.R12) / OVERLAP_SCALE;
221
+ }
222
+ return sum;
223
+ };
224
+ const edges = [...current.keys()];
225
+ for (let pass = 0; pass < Math.max(1, Math.floor(o.passes)); pass++) {
226
+ let moved = 0;
227
+ for (const edge of edges) {
228
+ const now = current.get(edge)!;
229
+ const base = costOf(edge, now);
230
+ const cap = Math.max(o.detour, detourOf(now.route.points));
231
+ let best: { shape: Shape; cost: number } | undefined;
232
+ for (const r of pool.get(edge) ?? []) {
233
+ if (r === now.route || detourOf(r.points) > cap + 1e-9) continue;
234
+ const shape = shapeOf(r);
235
+ const c = costOf(edge, shape);
236
+ if (c < (best?.cost ?? base) - 1e-9) best = { shape, cost: c };
237
+ }
238
+ if (best) {
239
+ current.set(edge, best.shape);
240
+ moved++;
241
+ }
242
+ }
243
+ if (moved === 0) break;
244
+ }
245
+ const routes = first.result.routes.map((r) => current.get(r.edge)?.route ?? r);
246
+ return { graph: first.graph, result: { routes } };
247
+ }
@@ -0,0 +1,144 @@
1
+ import { checkProperties } from "../../bench/properties.ts";
2
+ import { segmentsCross, type Point } from "../../geom.ts";
3
+ import type { Graph, Result } from "../../model.ts";
4
+ import { turnsOf } from "../../routers/reroute/index.ts";
5
+ import type { WholeOutput } from "../../strategies/model.ts";
6
+ import { runPipeline } from "../pipeline/index.ts";
7
+ import type { PortfolioSetting } from "../portfolio/index.ts";
8
+
9
+ /** The value of the loads of the edges of a board that the pick reads: the median, the quantile 0.95 or the mean. */
10
+ export type TracingQuantile = "q50" | "q95" | "mean";
11
+
12
+ /** The values of `TracingQuantile`. */
13
+ export const TRACING_QUANTILES: readonly TracingQuantile[] = Object.freeze(["q50", "q95", "mean"]);
14
+
15
+ /** The weights of the places to choose on the way of one edge (WILD-057). */
16
+ export interface TracingWeights {
17
+ /** The weight of a crossing, from 0 to 4. The default is 1. */
18
+ readonly crossing: number;
19
+ /** The weight of a merge or a split where the edge is not an outer lane, from 0 to 4. The default is 2. */
20
+ readonly merge: number;
21
+ /** The weight of a bend, from 0 to 4. The default is 0.5. */
22
+ readonly bend: number;
23
+ }
24
+
25
+ /** The default weights of the tracing load, as the record gives them. */
26
+ export const TRACING_WEIGHTS: Readonly<TracingWeights> = Object.freeze({ crossing: 1, merge: 2, bend: 0.5 });
27
+
28
+ /** The options of the whole strategy `tracing` (WILD-057). */
29
+ export interface TracingOptions extends TracingWeights {
30
+ /** The number of settings that the strategy tries, from the start of its list, from 1 to 12. The default is 4. */
31
+ readonly settings: number;
32
+ /** The value of the loads that the pick reads. The default is `q95`. */
33
+ readonly quantile: TracingQuantile;
34
+ }
35
+
36
+ /** The default options of the strategy `tracing`. */
37
+ export const TRACING_DEFAULTS: Readonly<TracingOptions> = Object.freeze({ ...TRACING_WEIGHTS, settings: 4, quantile: "q95" });
38
+
39
+ /** The bounds of a polyline: the least x and y, and the largest x and y. */
40
+ function boundsOf(p: readonly Point[]): [number, number, number, number] {
41
+ let x0 = Infinity;
42
+ let y0 = Infinity;
43
+ let x1 = -Infinity;
44
+ let y1 = -Infinity;
45
+ for (const q of p) {
46
+ x0 = Math.min(x0, q.x);
47
+ y0 = Math.min(y0, q.y);
48
+ x1 = Math.max(x1, q.x);
49
+ y1 = Math.max(y1, q.y);
50
+ }
51
+ return [x0, y0, x1, y1];
52
+ }
53
+
54
+ /**
55
+ * This function gives the tracing load of each edge of a result (WILD-057, after the tracing tasks of Ware, Purchase, Colpoys and McGill 2002).
56
+ * The load is `T(e) = crossing × crossings + merge × (merges and splits where e is not an outer lane) + bend × bends`.
57
+ * A shared run where the edge is not an outer lane gives one merge and one split. Such a bundle gives one split.
58
+ * A bend is a turn of more than 1 degree. The measure is W4 of MEAS3.
59
+ */
60
+ export function tracingLoads(result: Result, weights: Partial<TracingWeights> = {}): Map<string, number> {
61
+ const w = { ...TRACING_WEIGHTS, ...weights };
62
+ const routes = result.routes;
63
+ const bounds = routes.map((r) => boundsOf(r.points));
64
+ const crossings = new Array<number>(routes.length).fill(0);
65
+ for (let i = 0; i < routes.length; i++) {
66
+ const a = routes[i]!.points;
67
+ const [ax0, ay0, ax1, ay1] = bounds[i]!;
68
+ for (let j = i + 1; j < routes.length; j++) {
69
+ const [bx0, by0, bx1, by1] = bounds[j]!;
70
+ if (bx0 > ax1 || bx1 < ax0 || by0 > ay1 || by1 < ay0) continue;
71
+ const b = routes[j]!.points;
72
+ let n = 0;
73
+ for (let p = 1; p < a.length; p++) for (let q = 1; q < b.length; q++) if (segmentsCross(a[p - 1]!, a[p]!, b[q - 1]!, b[q]!)) n++;
74
+ crossings[i]! += n;
75
+ crossings[j]! += n;
76
+ }
77
+ }
78
+ const choices = new Map<string, number>();
79
+ const inner = (edges: readonly string[], add: number): void => {
80
+ if (edges.length < 3) return;
81
+ for (const e of edges.slice(1, -1)) choices.set(e, (choices.get(e) ?? 0) + add);
82
+ };
83
+ for (const run of result.runs ?? []) inner(run.edges, 2);
84
+ for (const b of result.bundles ?? []) inner(b.edges, 1);
85
+ const out = new Map<string, number>();
86
+ routes.forEach((r, i) => out.set(r.edge, w.crossing * crossings[i]! + w.merge * (choices.get(r.edge) ?? 0) + w.bend * turnsOf(r.points)));
87
+ return out;
88
+ }
89
+
90
+ /** This function gives a value of a list of loads: the median, the quantile 0.95 by the nearest rank, or the mean. An empty list gives 0. */
91
+ export function tracingValue(loads: Iterable<number>, quantile: TracingQuantile): number {
92
+ const list = [...loads].sort((a, b) => a - b);
93
+ if (list.length === 0) return 0;
94
+ if (quantile === "mean") return list.reduce((s, v) => s + v, 0) / list.length;
95
+ const q = quantile === "q50" ? 0.5 : 0.95;
96
+ return list[Math.min(list.length - 1, Math.max(0, Math.ceil(q * list.length) - 1))]!;
97
+ }
98
+
99
+ /** The tracing loads of one setting on the board: the value that the pick reads, and the median for a tie. */
100
+ export interface TracingEntry {
101
+ readonly name: string;
102
+ readonly value: number;
103
+ readonly median: number;
104
+ }
105
+
106
+ /** The result of `tracing`: the output, the name of the pick and the loads of each setting with no break of a hard rule. */
107
+ export interface TracingRun {
108
+ readonly output: WholeOutput;
109
+ readonly pick: string;
110
+ readonly entries: readonly TracingEntry[];
111
+ }
112
+
113
+ /**
114
+ * This function runs the whole strategy `tracing` (WILD-057). It runs the first `settings` settings of `list` on the board.
115
+ * A setting with a break of a hard rule, or above its node limit, does not count. The setting with the least tracing load wins.
116
+ * A tie goes to the least median load, then to the earlier setting. It reads only the board and the outputs of its own settings.
117
+ */
118
+ export function tracing(graph: Graph, list: readonly PortfolioSetting[], options: Partial<TracingOptions> = {}): TracingRun {
119
+ const o = { ...TRACING_DEFAULTS, ...options };
120
+ const count = Math.max(1, Math.min(list.length, Math.floor(o.settings)));
121
+ const entries: TracingEntry[] = [];
122
+ const outputs: WholeOutput[] = [];
123
+ let fallback: { name: string; output: WholeOutput } | undefined;
124
+ for (const s of list.slice(0, count)) {
125
+ const limit = (s.pipeline.highway ?? s.pipeline.router).maxNodes;
126
+ if (limit !== undefined && graph.nodes.length > limit) continue;
127
+ const output = runPipeline(graph, s.pipeline);
128
+ fallback ??= { name: s.name, output };
129
+ if (checkProperties(output.graph, output.result, 1).length > 0) continue;
130
+ const loads = [...tracingLoads(output.result, o).values()];
131
+ entries.push({ name: s.name, value: tracingValue(loads, o.quantile), median: tracingValue(loads, "q50") });
132
+ outputs.push(output);
133
+ }
134
+ if (entries.length === 0) {
135
+ const first = fallback ?? { name: list[0]!.name, output: runPipeline(graph, list[0]!.pipeline) };
136
+ return { output: first.output, pick: first.name, entries };
137
+ }
138
+ let best = 0;
139
+ entries.forEach((e, i) => {
140
+ const b = entries[best]!;
141
+ if (e.value < b.value - 1e-9 || (Math.abs(e.value - b.value) <= 1e-9 && e.median < b.median - 1e-9)) best = i;
142
+ });
143
+ return { output: outputs[best]!, pick: entries[best]!.name, entries };
144
+ }