@mark1russell7/highways 0.9.4 → 0.9.5

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,243 @@
1
+ import { checkProperties } from "../../bench/properties.ts";
2
+ import { dist, polylineLength, segmentsCross, type Point } from "../../geom.ts";
3
+ import type { Graph, Route } from "../../model.ts";
4
+ import type { WholeOutput } from "../../strategies/model.ts";
5
+ import { runPipeline } from "../pipeline/index.ts";
6
+ import type { PortfolioSetting } from "../portfolio/index.ts";
7
+
8
+ /** One rule of the curriculum: the crossings (R1), the lane separation (R12) or the crossing angle (R2). */
9
+ export type CurriculumRule = "R1" | "R12" | "R2";
10
+
11
+ /** The order in which the curriculum adds its rules. */
12
+ export type CurriculumOrder = "R1-R12-R2" | "R12-R1-R2" | "R1-R2-R12";
13
+
14
+ /** The values of `CurriculumOrder`. */
15
+ export const CURRICULUM_ORDERS: readonly CurriculumOrder[] = Object.freeze(["R1-R12-R2", "R12-R1-R2", "R1-R2-R12"]);
16
+
17
+ /** The options of the whole strategy `curriculum` (RULE-048). */
18
+ export interface CurriculumOptions {
19
+ /** The order in which the stages add the rules. The default is `R1-R12-R2`. */
20
+ readonly order: CurriculumOrder;
21
+ /** The number of passes over the edges in each stage, from 1 to 3. The default is 1. */
22
+ readonly passes: number;
23
+ /** The number of settings in the pool of routes, from the start of its list, from 2 to 3. The default is 3. */
24
+ readonly settings: number;
25
+ /** 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. */
26
+ readonly detour: number;
27
+ }
28
+
29
+ /** The default options of the strategy `curriculum`. */
30
+ export const CURRICULUM_DEFAULTS: Readonly<CurriculumOptions> = Object.freeze({ order: "R1-R12-R2", passes: 1, settings: 3, detour: 2 });
31
+
32
+ /** Two legs closer than this distance count for the lane separation (R12), as in the screen. */
33
+ const NEAR = 4;
34
+
35
+ /** A route with its bounding box, for the quick test of two routes. */
36
+ interface Shape {
37
+ readonly route: Route;
38
+ readonly x0: number;
39
+ readonly y0: number;
40
+ readonly x1: number;
41
+ readonly y1: number;
42
+ }
43
+
44
+ /** This function gives the shape of a route. */
45
+ function shapeOf(route: Route): Shape {
46
+ let x0 = Infinity;
47
+ let y0 = Infinity;
48
+ let x1 = -Infinity;
49
+ let y1 = -Infinity;
50
+ for (const p of route.points) {
51
+ x0 = Math.min(x0, p.x);
52
+ y0 = Math.min(y0, p.y);
53
+ x1 = Math.max(x1, p.x);
54
+ y1 = Math.max(y1, p.y);
55
+ }
56
+ return { route, x0, y0, x1, y1 };
57
+ }
58
+
59
+ /** The sine of 5 degrees, as a literal. A literal gives the same bits on each platform, so a decision does not depend on `Math.sin`. */
60
+ const SIN_5 = 0.08715574274765817;
61
+
62
+ /** The constants of the arc sine of fdlibm (`e_asin.c`). */
63
+ const PIO2_HI = 1.5707963267948966;
64
+ const PIO2_LO = 6.123233995736766e-17;
65
+ const PIO4_HI = 0.7853981633974483;
66
+ const PS = [1.66666666666666657415e-1, -3.25565818622400915405e-1, 2.01212532134862925881e-1, -4.00555345006794114027e-2, 7.91534994289814532176e-4, 3.47933107596021167570e-5] as const;
67
+ const QS = [-2.40339491173441421878, 2.02094576023350569471, -6.88283971605453293030e-1, 7.70381505559019352791e-2] as const;
68
+ const WORDS = new DataView(new ArrayBuffer(8));
69
+
70
+ /** This function gives the rational part `p / q` of the arc sine of fdlibm at `t`. */
71
+ function asinRatio(t: number): [number, number] {
72
+ const p = t * (PS[0] + t * (PS[1] + t * (PS[2] + t * (PS[3] + t * (PS[4] + t * PS[5])))));
73
+ const q = 1 + t * (QS[0] + t * (QS[1] + t * (QS[2] + t * QS[3])));
74
+ return [p, q];
75
+ }
76
+
77
+ /**
78
+ * This function gives the arc sine of `x` in radians, from -1 to 1. It is the algorithm of fdlibm, with only `+`, `*`, `/` and `Math.sqrt`.
79
+ * These operations are exact to the last bit on each platform, so the result does not depend on the engine.
80
+ */
81
+ export function arcsin(x: number): number {
82
+ const ax = Math.abs(x);
83
+ if (Number.isNaN(x) || ax > 1) return Number.NaN;
84
+ if (ax === 1) return x * PIO2_HI + x * PIO2_LO;
85
+ if (ax < 0.5) {
86
+ if (ax < 2 ** -27) return x;
87
+ const [p, q] = asinRatio(x * x);
88
+ return x + x * (p / q);
89
+ }
90
+ const t = (1 - ax) * 0.5;
91
+ const [p, q] = asinRatio(t);
92
+ const s = Math.sqrt(t);
93
+ let r: number;
94
+ WORDS.setFloat64(0, ax);
95
+ if (WORDS.getUint32(0) >= 0x3fef3333) {
96
+ r = PIO2_HI - (2 * (s + s * (p / q)) - PIO2_LO);
97
+ } else {
98
+ WORDS.setFloat64(0, s);
99
+ WORDS.setUint32(4, 0);
100
+ const w = WORDS.getFloat64(0);
101
+ const c = (t - w * w) / (s + w);
102
+ const pp = 2 * s * (p / q) - (PIO2_LO - 2 * c);
103
+ const qq = PIO4_HI - 2 * w;
104
+ r = PIO4_HI - (pp - qq);
105
+ }
106
+ return x > 0 ? r : -r;
107
+ }
108
+
109
+ /** This function gives the distance between the segments `ab` and `cd`, when they do not cross. */
110
+ function segmentGap(a: Point, b: Point, c: Point, d: Point): number {
111
+ const at = (p: Point, u: Point, v: Point): number => {
112
+ const dx = v.x - u.x;
113
+ const dy = v.y - u.y;
114
+ const l2 = dx * dx + dy * dy;
115
+ const t = l2 > 0 ? Math.max(0, Math.min(1, ((p.x - u.x) * dx + (p.y - u.y) * dy) / l2)) : 0;
116
+ const ex = p.x - u.x - t * dx;
117
+ const ey = p.y - u.y - t * dy;
118
+ return Math.sqrt(ex * ex + ey * ey);
119
+ };
120
+ return Math.min(at(a, c, d), at(b, c, d), at(c, a, b), at(d, a, b));
121
+ }
122
+
123
+ /**
124
+ * This function gives the costs of the rules between two routes. R1 is the number of crossings.
125
+ * R2 is the sum of `1 - θ / 90` over the crossings. θ is the crossing angle in degrees.
126
+ * R12 is the overlap of the legs of `a` and `b` that are within 5 degrees of parallel. The two legs are nearer than 4 units.
127
+ */
128
+ export function pairCosts(a: readonly Point[], b: readonly Point[]): Record<CurriculumRule, number> {
129
+ let r1 = 0;
130
+ let r2 = 0;
131
+ let r12 = 0;
132
+ for (let i = 1; i < a.length; i++) {
133
+ const p = a[i - 1]!;
134
+ const q = a[i]!;
135
+ const ux = q.x - p.x;
136
+ const uy = q.y - p.y;
137
+ const lu = Math.sqrt(ux * ux + uy * uy);
138
+ if (lu <= 1e-9) continue;
139
+ for (let j = 1; j < b.length; j++) {
140
+ const r = b[j - 1]!;
141
+ const s = b[j]!;
142
+ const vx = s.x - r.x;
143
+ const vy = s.y - r.y;
144
+ const lv = Math.sqrt(vx * vx + vy * vy);
145
+ if (lv <= 1e-9) continue;
146
+ if (segmentsCross(p, q, r, s)) {
147
+ r1++;
148
+ const angle = (arcsin(Math.min(1, Math.abs(ux * vy - uy * vx) / (lu * lv))) * 180) / Math.PI;
149
+ r2 += 1 - angle / 90;
150
+ continue;
151
+ }
152
+ if (Math.abs(ux * vy - uy * vx) > SIN_5 * lu * lv) continue;
153
+ const t0 = ((r.x - p.x) * ux + (r.y - p.y) * uy) / lu;
154
+ const t1 = ((s.x - p.x) * ux + (s.y - p.y) * uy) / lu;
155
+ const overlap = Math.min(lu, Math.max(t0, t1)) - Math.max(0, Math.min(t0, t1));
156
+ if (overlap > 1e-9 && segmentGap(p, q, r, s) < NEAR) r12 += overlap;
157
+ }
158
+ }
159
+ return { R1: r1, R2: r2, R12: r12 };
160
+ }
161
+
162
+ /** This function gives the detour of a route: its length over the distance between its ends. */
163
+ function detourOf(points: readonly Point[]): number {
164
+ const span = dist(points[0]!, points[points.length - 1]!);
165
+ return span > 1e-9 ? polylineLength(points) / span : 1;
166
+ }
167
+
168
+ /**
169
+ * This function runs the whole strategy `curriculum` (RULE-048, after curriculum learning, Bengio 2009). The record has no energy solver yet (RULE-019).
170
+ * Thus a pool of routes of fixed settings takes its place. Each edge can take its route from any setting of the pool.
171
+ * The first answer is the first setting. Each stage adds one rule, in the order `order`. A pass of a stage visits the edges, the worst first.
172
+ * An edge takes the route of the pool with the least cost of the new rule. That cost is lower, and the earlier rules do not get worse.
173
+ * A route with a break of a hard rule is not in the pool. The output has the routes alone, with no runs, bundles or levels.
174
+ */
175
+ export function curriculum(graph: Graph, list: readonly PortfolioSetting[], options: Partial<CurriculumOptions> = {}): WholeOutput {
176
+ const o = { ...CURRICULUM_DEFAULTS, ...options };
177
+ const count = Math.max(1, Math.min(list.length, Math.floor(o.settings)));
178
+ const pool = new Map<string, Route[]>();
179
+ let first: WholeOutput | undefined;
180
+ for (const s of list.slice(0, count)) {
181
+ const limit = (s.pipeline.highway ?? s.pipeline.router).maxNodes;
182
+ if (limit !== undefined && graph.nodes.length > limit) continue;
183
+ const output = runPipeline(graph, s.pipeline);
184
+ first ??= output;
185
+ const faulty = new Set(checkProperties(output.graph, output.result, 1_000_000).map((v) => v.edge));
186
+ for (const r of output.result.routes) {
187
+ if (faulty.has(r.edge) || r.points.length < 2) continue;
188
+ const list2 = pool.get(r.edge);
189
+ if (list2) list2.push(r);
190
+ else pool.set(r.edge, [r]);
191
+ }
192
+ }
193
+ if (!first) return runPipeline(graph, list[0]!.pipeline);
194
+ const rules = o.order.split("-") as CurriculumRule[];
195
+ const current = new Map<string, Shape>(first.result.routes.map((r) => [r.edge, shapeOf(r)]));
196
+ // A faulty first route takes the first route of the pool, so no stage starts from a break of a hard rule.
197
+ for (const [edge, shape] of current) {
198
+ const own = pool.get(edge);
199
+ if (own && !own.includes(shape.route)) current.set(edge, shapeOf(own[0]!));
200
+ }
201
+ const costOf = (edge: string, s: Shape): Record<CurriculumRule, number> => {
202
+ const sum = { R1: 0, R2: 0, R12: 0 };
203
+ for (const [other, t] of current) {
204
+ if (other === edge || t.x0 > s.x1 + NEAR || t.x1 < s.x0 - NEAR || t.y0 > s.y1 + NEAR || t.y1 < s.y0 - NEAR) continue;
205
+ const c = pairCosts(s.route.points, t.route.points);
206
+ sum.R1 += c.R1;
207
+ sum.R2 += c.R2;
208
+ sum.R12 += c.R12;
209
+ }
210
+ return sum;
211
+ };
212
+ const edges = [...current.keys()];
213
+ for (let stage = 0; stage < rules.length; stage++) {
214
+ const rule = rules[stage]!;
215
+ const earlier = rules.slice(0, stage);
216
+ for (let pass = 0; pass < Math.max(1, Math.floor(o.passes)); pass++) {
217
+ const costs = new Map(edges.map((e) => [e, costOf(e, current.get(e)!)]));
218
+ const order = [...edges].sort((a, b) => costs.get(b)![rule] - costs.get(a)![rule] || (a < b ? -1 : a > b ? 1 : 0));
219
+ let moved = 0;
220
+ for (const edge of order) {
221
+ const now = current.get(edge)!;
222
+ const base = costOf(edge, now);
223
+ if (base[rule] <= 1e-9) continue;
224
+ const cap = Math.max(o.detour, detourOf(now.route.points));
225
+ let best: { shape: Shape; cost: number } | undefined;
226
+ for (const r of pool.get(edge) ?? []) {
227
+ if (r === now.route || detourOf(r.points) > cap + 1e-9) continue;
228
+ const shape = shapeOf(r);
229
+ const c = costOf(edge, shape);
230
+ if (c[rule] >= base[rule] - 1e-9 || earlier.some((k) => c[k] > base[k] + 1e-9)) continue;
231
+ if (!best || c[rule] < best.cost - 1e-9) best = { shape, cost: c[rule] };
232
+ }
233
+ if (best) {
234
+ current.set(edge, best.shape);
235
+ moved++;
236
+ }
237
+ }
238
+ if (moved === 0) break;
239
+ }
240
+ }
241
+ const routes = first.result.routes.map((r) => current.get(r.edge)?.route ?? r);
242
+ return { graph: first.graph, result: { routes } };
243
+ }
@@ -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
+ }