@mark1russell7/highways 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +59 -2
  2. package/build/index.d.ts +2333 -193
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +13042 -2334
  5. package/build/index.js.map +1 -1
  6. package/package.json +12 -1
  7. package/src/bench/exact/index.ts +258 -0
  8. package/src/bench/exact/optima-exact-v1-bounds.json +14409 -0
  9. package/src/bench/exact/optima-exact-v1.json +4809 -0
  10. package/src/bench/exact/solve.ts +129 -0
  11. package/src/bench/measures/index.ts +1 -0
  12. package/src/bench/measures/lanes.ts +304 -0
  13. package/src/bench/measures/levels.ts +86 -2
  14. package/src/bench/screen/activation.ts +3 -0
  15. package/src/bench/screen/bounds.ts +140 -0
  16. package/src/bench/screen/compare.ts +26 -0
  17. package/src/bench/screen/exact-set.ts +100 -0
  18. package/src/bench/screen/exercise.ts +290 -0
  19. package/src/bench/screen/forms.ts +25 -5
  20. package/src/bench/screen/gap.ts +316 -0
  21. package/src/bench/screen/hash.ts +7 -76
  22. package/src/bench/screen/index.ts +6 -0
  23. package/src/bench/screen/measures.ts +89 -1
  24. package/src/bench/screen/portfolio-pick.ts +20 -0
  25. package/src/bench/screen/screen.ts +78 -2
  26. package/src/bench/screen/set.ts +3 -2
  27. package/src/channels.ts +56 -0
  28. package/src/highways/areas/index.ts +160 -0
  29. package/src/highways/confluent/index.ts +154 -0
  30. package/src/highways/confluent/k23.test-util.ts +8 -0
  31. package/src/highways/consensus/index.ts +129 -0
  32. package/src/highways/crossbars/index.ts +184 -0
  33. package/src/highways/cubu/index.ts +26 -0
  34. package/src/highways/diffusion/index.ts +103 -0
  35. package/src/highways/divided/index.ts +111 -0
  36. package/src/highways/edge-paths/index.ts +155 -0
  37. package/src/highways/fdeb/index.ts +193 -0
  38. package/src/highways/fixtures.test-util.ts +71 -0
  39. package/src/highways/flow-clusters/index.ts +143 -0
  40. package/src/highways/frechet/index.ts +232 -0
  41. package/src/highways/growth/index.ts +148 -0
  42. package/src/highways/hdbscan/index.ts +250 -0
  43. package/src/highways/hierarchy/index.ts +140 -0
  44. package/src/highways/hough/index.ts +182 -0
  45. package/src/highways/hub-arcs/index.ts +181 -0
  46. package/src/highways/index.ts +79 -0
  47. package/src/highways/kdeeb/index.ts +355 -0
  48. package/src/highways/lanes/index.ts +189 -0
  49. package/src/highways/link-communities/index.ts +189 -0
  50. package/src/highways/mesh/index.ts +209 -0
  51. package/src/highways/mingle/index.ts +171 -0
  52. package/src/highways/mlseb/index.ts +204 -0
  53. package/src/highways/nested/index.ts +143 -0
  54. package/src/highways/p-hub/index.ts +248 -0
  55. package/src/highways/power/index.ts +117 -0
  56. package/src/highways/resistance/index.ts +159 -0
  57. package/src/highways/ridges/index.ts +328 -0
  58. package/src/highways/sbeb/index.ts +303 -0
  59. package/src/highways/scale-space/index.ts +135 -0
  60. package/src/highways/snap.ts +110 -0
  61. package/src/highways/soft-keys/index.ts +114 -0
  62. package/src/highways/spectral/index.ts +391 -0
  63. package/src/highways/spiral/index.ts +205 -0
  64. package/src/highways/territories/index.ts +150 -0
  65. package/src/highways/trace.ts +153 -0
  66. package/src/highways/traclus/index.ts +296 -0
  67. package/src/index.ts +2 -0
  68. package/src/portfolio/bands.ts +33 -0
  69. package/src/portfolio/fit.ts +29 -0
  70. package/src/portfolio/index.ts +6 -0
  71. package/src/portfolio/key.ts +98 -0
  72. package/src/portfolio/members.ts +171 -0
  73. package/src/portfolio/router.ts +133 -0
  74. package/src/portfolio/sets.ts +28 -0
  75. package/src/routers/shared.ts +56 -3
  76. package/src/strategies/builtins.ts +376 -1
  77. package/src/strategies/schemas.ts +460 -1
  78. package/src/visibility.ts +5 -0
@@ -0,0 +1,129 @@
1
+ import { follow } from "../../highways/follow.ts";
2
+ import type { HighwayPlan } from "../../highways/model.ts";
3
+ import type { Graph, Route } from "../../model.ts";
4
+ import { STRATEGIES } from "../../strategies/builtins.ts";
5
+ import { asRouter, getStrategy } from "../../strategies/model.ts";
6
+ import { inkSaved } from "../measures/bundles.ts";
7
+ import { maxStretch } from "../screen/gap.ts";
8
+ import { exactModel, type ExactRule } from "./index.ts";
9
+
10
+ /**
11
+ * The part of a persistent model of the package `highs` that `solveExact` uses.
12
+ * The library does not import `highs`. The caller gives the loaded runtime, so `highs` stays a devDependency.
13
+ */
14
+ export interface HighsModelLike {
15
+ readonly options: { set(values: Readonly<Record<string, string | number | boolean>>): unknown };
16
+ readonly info: { get(name: string): number | bigint };
17
+ run(): unknown;
18
+ getModelStatus(): number;
19
+ getObjectiveValue(): number;
20
+ getSolution(): { readonly colValue: ArrayLike<number> };
21
+ getDimensions(): { readonly numCols: number };
22
+ getColName(index: number): string;
23
+ dispose(): void;
24
+ }
25
+
26
+ /** The part of the loaded runtime of the package `highs` that `solveExact` uses. */
27
+ export interface HighsLike {
28
+ createModel(source: { readonly format: "lp"; readonly data: string }): HighsModelLike;
29
+ readonly constants: { readonly modelStatus: { readonly optimal: number; readonly timeLimit: number } };
30
+ }
31
+
32
+ /** The result of one solve of the exact model. */
33
+ export interface ExactSolve {
34
+ /** The model status of HiGHS, for example `optimal` or `time-limit`. */
35
+ readonly status: string;
36
+ /** This value is `true` when HiGHS proved the optimum, with a relative MIP gap of 0. */
37
+ readonly proven: boolean;
38
+ /** The objective of the best solution, or `null` without a solution. */
39
+ readonly objective: number | null;
40
+ /** The dual bound of HiGHS at the end of the solve, or `null` when HiGHS gives none. */
41
+ readonly bound: number | null;
42
+ /** The relative MIP gap of HiGHS at the end of the solve: `|objective - bound| / |objective|`. It is `null` without a solution. */
43
+ readonly mipGap: number | null;
44
+ /** The time of the solve, in seconds, without the time to make the model. */
45
+ readonly seconds: number;
46
+ /** The size of the model: the edges with a way in the stretch limit, the binary variables and the constraints. */
47
+ readonly edges: number;
48
+ readonly binaries: number;
49
+ readonly rows: number;
50
+ /** The plan of the best solution. It is empty without a solution. */
51
+ readonly plan: HighwayPlan;
52
+ /** The ink saved (B1) of the model itself: `1 - (the ink of the open links) / (the length of the ways)`. It is `null` without a solution. */
53
+ readonly modelB1: number | null;
54
+ }
55
+
56
+ /** This function gives a finite number of the info of HiGHS, or `null`. */
57
+ function infoNumber(model: HighsModelLike, name: string): number | null {
58
+ try {
59
+ const v = Number(model.info.get(name));
60
+ return Number.isFinite(v) ? v : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * This function makes the exact model of a graph and solves it with HiGHS.
68
+ * The relative MIP gap is 0, and the time limit is `seconds`.
69
+ * It gives the status, the proof, the bound, the gap, the time and the plan of the best solution.
70
+ */
71
+ export function solveExact(highs: HighsLike, graph: Graph, o: { readonly stretch: number; readonly rule: ExactRule; readonly seconds: number }): ExactSolve {
72
+ const model = exactModel(graph, { stretch: o.stretch, rule: o.rule });
73
+ const size = { edges: model.edges, binaries: model.columns, rows: model.rows };
74
+ const m = highs.createModel({ format: "lp", data: model.lp });
75
+ try {
76
+ m.options.set({ time_limit: o.seconds, output_flag: false, mip_rel_gap: 0 });
77
+ const start = Date.now();
78
+ m.run();
79
+ const seconds = (Date.now() - start) / 1000;
80
+ const code = m.getModelStatus();
81
+ const statuses = highs.constants.modelStatus;
82
+ const status = code === statuses.optimal ? "optimal" : code === statuses.timeLimit ? "time-limit" : `status-${code}`;
83
+ // The value 2 of `primal_solution_status` is a feasible solution.
84
+ const found = infoNumber(m, "primal_solution_status") === 2;
85
+ if (!found) return { status, proven: false, objective: null, bound: infoNumber(m, "mip_dual_bound"), mipGap: null, seconds, ...size, plan: { highways: [] }, modelB1: null };
86
+ const values = m.getSolution().colValue;
87
+ const byName = new Map<string, number>();
88
+ const cols = m.getDimensions().numCols;
89
+ for (let i = 0; i < cols; i++) byName.set(m.getColName(i), values[i]!);
90
+ const value = (name: string): number => byName.get(name) ?? 0;
91
+ const plan = model.decode(value);
92
+ // The ink is the sum of the lengths of the open links (`y`), and the length of the ways is the sum of the lengths of the arcs (`x`).
93
+ let ink = 0;
94
+ let ways = 0;
95
+ for (const [name, len] of model.lengths) {
96
+ if (value(name) <= 0.5) continue;
97
+ if (name.startsWith("y")) ink += len;
98
+ else ways += len;
99
+ }
100
+ const mipGap = infoNumber(m, "mip_gap");
101
+ return {
102
+ status,
103
+ proven: code === statuses.optimal,
104
+ objective: m.getObjectiveValue(),
105
+ bound: infoNumber(m, "mip_dual_bound"),
106
+ mipGap: code === statuses.optimal ? (mipGap ?? 0) : mipGap,
107
+ seconds,
108
+ ...size,
109
+ plan,
110
+ modelB1: ways > 0 ? Math.max(0, 1 - ink / ways) : 0,
111
+ };
112
+ } finally {
113
+ m.dispose();
114
+ }
115
+ }
116
+
117
+ /** The routes of a plan after `follow`, with the ink saved (B1) and the largest bundle stretch (B3) against the routes of `corners`. */
118
+ export interface PlanMeasures {
119
+ readonly routes: readonly Route[];
120
+ readonly b1: number;
121
+ readonly b3max: number;
122
+ }
123
+
124
+ /** This function routes a plan with `follow`, and gives B1 and the largest B3 of the routes. The base of B3 is the router `corners`. */
125
+ export function planMeasures(graph: Graph, plan: HighwayPlan): PlanMeasures {
126
+ const routes = follow(graph, plan).routes;
127
+ const corners = asRouter(getStrategy(STRATEGIES, "router", "corners")).route(graph).routes;
128
+ return { routes, b1: inkSaved(routes), b3max: maxStretch(routes, corners) };
129
+ }
@@ -9,3 +9,4 @@ export * from "./room.ts";
9
9
  export * from "./pareto.ts";
10
10
  export * from "./order.ts";
11
11
  export * from "./highways.ts";
12
+ export * from "./lanes.ts";
@@ -0,0 +1,304 @@
1
+ import { dist, frechet, pointSegmentDistance, segmentsCross, type Point } from "../../geom.ts";
2
+ import { Grid } from "../../grid.ts";
3
+ import type { Graph, Result, Route } from "../../model.ts";
4
+ import { laneRoutes, runKey, segmentsOf, type SegmentGraph } from "../../segments.ts";
5
+ import { measureRouteFaults } from "../links.ts";
6
+ import { segmentKey } from "./routes.ts";
7
+ import { quantile } from "./stats.ts";
8
+
9
+ /**
10
+ * The lane width of the lane measures, in units of the board. The room bench opens the lanes at the same width (`spacing` of `measureBoard`).
11
+ * R12 also uses it as the smallest correct distance between two parallel legs.
12
+ */
13
+ export const MEASURE_LANE_WIDTH = 4;
14
+
15
+ /** The routes of a result with each shared segment open: one lane for each edge. */
16
+ export interface OpenLanes {
17
+ /** The segments of the result. The shared segments are the runs and the trunks of the bundles. */
18
+ readonly graph: SegmentGraph;
19
+ /** The lane route of each edge, from `laneRoutes`. Without a shared segment, the lane routes are the routes. */
20
+ readonly lanes: readonly Route[];
21
+ /** The distance between two lanes. */
22
+ readonly width: number;
23
+ }
24
+
25
+ /** This function opens each shared segment of a result: each edge of a run or of a trunk gets its own lane, `width` apart. */
26
+ export function openLanes(result: Result, width: number = MEASURE_LANE_WIDTH): OpenLanes {
27
+ const graph = segmentsOf(result);
28
+ const open = new Set(graph.segments.filter((s) => s.count > 1).map((s) => runKey(s.edges)));
29
+ return { graph, lanes: laneRoutes(graph, open, width), width };
30
+ }
31
+
32
+ /** One drawn segment and the sorted ids of the edges that draw it. */
33
+ interface Leg {
34
+ readonly a: Point;
35
+ readonly b: Point;
36
+ readonly edges: string;
37
+ }
38
+
39
+ /** This function gives each drawn segment of the routes one time, with the edges that draw it. A segment shorter than the grain of `segmentKey` is not a leg. */
40
+ function legsOf(routes: readonly Route[]): Leg[] {
41
+ const byKey = new Map<string, { a: Point; b: Point; edges: Set<string> }>();
42
+ for (const r of routes) {
43
+ for (let i = 1; i < r.points.length; i++) {
44
+ const a = r.points[i - 1]!;
45
+ const b = r.points[i]!;
46
+ const k = segmentKey(a, b);
47
+ if (k === undefined) continue;
48
+ const leg = byKey.get(k);
49
+ if (leg) leg.edges.add(r.edge);
50
+ else byKey.set(k, { a, b, edges: new Set([r.edge]) });
51
+ }
52
+ }
53
+ return [...byKey.values()].map((l) => ({ a: l.a, b: l.b, edges: [...l.edges].sort().join("\u0000") }));
54
+ }
55
+
56
+ /** This function gives the distance between the segments `ab` and `cd`. It is 0 when they cross or touch. */
57
+ function segmentDistance(a: Point, b: Point, c: Point, d: Point): number {
58
+ if (segmentsCross(a, b, c, d)) return 0;
59
+ return Math.min(pointSegmentDistance(a, c, d), pointSegmentDistance(b, c, d), pointSegmentDistance(c, a, b), pointSegmentDistance(d, a, b));
60
+ }
61
+
62
+ /** This function gives the length of the union of intervals. */
63
+ function unionLength(intervals: [number, number][]): number {
64
+ intervals.sort((p, q) => p[0] - q[0]);
65
+ let sum = 0;
66
+ let lo = Number.NEGATIVE_INFINITY;
67
+ let hi = Number.NEGATIVE_INFINITY;
68
+ for (const [a, b] of intervals) {
69
+ if (a > hi) {
70
+ if (hi > lo) sum += hi - lo;
71
+ lo = a;
72
+ hi = b;
73
+ } else hi = Math.max(hi, b);
74
+ }
75
+ if (hi > lo) sum += hi - lo;
76
+ return sum;
77
+ }
78
+
79
+ /**
80
+ * This function gives the lane separation of the routes (R12 of MEAS0): the share of the ink that runs too near a parallel leg of other edges.
81
+ *
82
+ * A leg is a drawn segment of `S`, and a leg that several routes share counts one time, as in B1. Two legs are near when four things are true.
83
+ * The sets of their edges are different. They are within `maxAngle` degrees of parallel. Their projections overlap. Their distance is less than `near`.
84
+ *
85
+ * `ns = Σ_s length(the part of s that the projections of its near legs cover) / ink(S)`. Each part counts one time, so the scale is 0 to 1.
86
+ * Lower is better. Routes without ink give 0. A grid of buckets finds the near legs, so the cost is `n log n` for an even density.
87
+ */
88
+ export function routeSeparation(routes: readonly Route[], near: number = MEASURE_LANE_WIDTH, maxAngle = 5): number {
89
+ const legs = legsOf(routes);
90
+ let ink = 0;
91
+ for (const l of legs) ink += dist(l.a, l.b);
92
+ if (ink <= 0) return 0;
93
+ const grid = new Grid<number>(Math.max(16, 4 * near));
94
+ legs.forEach((l, i) => grid.addSegment(i, l.a, l.b));
95
+ const sin = Math.sin((maxAngle * Math.PI) / 180);
96
+ let close = 0;
97
+ legs.forEach((s, i) => {
98
+ const length = dist(s.a, s.b);
99
+ const ux = (s.b.x - s.a.x) / length;
100
+ const uy = (s.b.y - s.a.y) / length;
101
+ const cover: [number, number][] = [];
102
+ grid.querySegment(s.a, s.b, near, (j) => {
103
+ const t = legs[j]!;
104
+ if (j === i || t.edges === s.edges) return;
105
+ const tx = t.b.x - t.a.x;
106
+ const ty = t.b.y - t.a.y;
107
+ const tl = Math.hypot(tx, ty);
108
+ if (Math.abs(ux * ty - uy * tx) > sin * tl) return;
109
+ const p = (t.a.x - s.a.x) * ux + (t.a.y - s.a.y) * uy;
110
+ const q = (t.b.x - s.a.x) * ux + (t.b.y - s.a.y) * uy;
111
+ const lo = Math.max(0, Math.min(p, q));
112
+ const hi = Math.min(length, Math.max(p, q));
113
+ if (hi - lo <= 1e-9 || segmentDistance(s.a, s.b, t.a, t.b) >= near) return;
114
+ cover.push([lo, hi]);
115
+ });
116
+ close += unionLength(cover);
117
+ });
118
+ return Math.min(1, close / ink);
119
+ }
120
+
121
+ /**
122
+ * This function gives the orthogonality of the routes (R14 of MEAS0): `orth = Σ L(legs within maxAngle degrees of an axis) / Σ_e L(r_e)`.
123
+ * Each route counts its own legs, also on a shared segment. The scale is 0 to 1, and higher is better for an orthogonal style.
124
+ * Routes without length give 1. The cost is linear.
125
+ */
126
+ export function orthogonality(routes: readonly Route[], maxAngle = 1): number {
127
+ const tan = Math.tan((maxAngle * Math.PI) / 180);
128
+ let total = 0;
129
+ let straight = 0;
130
+ for (const r of routes) {
131
+ for (let i = 1; i < r.points.length; i++) {
132
+ const dx = Math.abs(r.points[i]!.x - r.points[i - 1]!.x);
133
+ const dy = Math.abs(r.points[i]!.y - r.points[i - 1]!.y);
134
+ const l = Math.hypot(dx, dy);
135
+ total += l;
136
+ if (Math.min(dx, dy) <= tan * Math.max(dx, dy)) straight += l;
137
+ }
138
+ }
139
+ return total > 0 ? straight / total : 1;
140
+ }
141
+
142
+ /**
143
+ * This function counts the lane crossings of the segments of a result (B5 of MEAS0): the pairs of edges whose lane order flips.
144
+ * Along the route of each edge `e`, it takes the shared segments in order. On each one, another edge `f` is on the left or on the right of `e`, in the way of `e`.
145
+ *
146
+ * A pair `(e, f)` counts one time when the side of `f` changes from one shared segment of both edges to the next one.
147
+ * Lower is better. A result without shared segments gives 0. The cost is the sum of the squares of the edge counts of the shared segments.
148
+ */
149
+ export function laneCrossings(graph: SegmentGraph): number {
150
+ const shared = new Map(graph.segments.filter((s) => s.count > 1).map((s) => [s.id, s] as const));
151
+ const flipped = new Set<string>();
152
+ for (const path of graph.paths) {
153
+ const side = new Map<string, number>();
154
+ for (const ref of path.segments) {
155
+ const s = shared.get(ref.segment);
156
+ if (!s) continue;
157
+ s.edges.forEach((f, lane) => {
158
+ if (f === path.edge) return;
159
+ const now = Math.sign(lane - ref.lane) * (ref.reverse ? -1 : 1);
160
+ const before = side.get(f);
161
+ if (before !== undefined && before !== now) flipped.add(path.edge < f ? `${path.edge}\u0000${f}` : `${f}\u0000${path.edge}`);
162
+ side.set(f, now);
163
+ });
164
+ }
165
+ }
166
+ return flipped.size;
167
+ }
168
+
169
+ /** This function adds the key of each cell of side `u` that the segment `ab` touches, with an exact walk over the cells (Amanatides and Woo, 1987). */
170
+ function touchCells(a: Point, b: Point, u: number, cells: Set<string>): void {
171
+ let i = Math.floor(a.x / u);
172
+ let j = Math.floor(a.y / u);
173
+ const iEnd = Math.floor(b.x / u);
174
+ const jEnd = Math.floor(b.y / u);
175
+ const dx = b.x - a.x;
176
+ const dy = b.y - a.y;
177
+ const sx = Math.sign(dx);
178
+ const sy = Math.sign(dy);
179
+ const deltaX = dx !== 0 ? u / Math.abs(dx) : Number.POSITIVE_INFINITY;
180
+ const deltaY = dy !== 0 ? u / Math.abs(dy) : Number.POSITIVE_INFINITY;
181
+ let maxX = dx !== 0 ? ((sx > 0 ? (i + 1) * u : i * u) - a.x) / dx : Number.POSITIVE_INFINITY;
182
+ let maxY = dy !== 0 ? ((sy > 0 ? (j + 1) * u : j * u) - a.y) / dy : Number.POSITIVE_INFINITY;
183
+ cells.add(`${i},${j}`);
184
+ while (i !== iEnd || j !== jEnd) {
185
+ // At a corner of four cells, the walk steps on both axes, so it does not touch the two side cells.
186
+ const moveX = j === jEnd || (i !== iEnd && maxX <= maxY);
187
+ const moveY = i === iEnd || (j !== jEnd && maxY <= maxX);
188
+ if (moveX) {
189
+ i += sx;
190
+ maxX += deltaX;
191
+ }
192
+ if (moveY) {
193
+ j += sy;
194
+ maxY += deltaY;
195
+ }
196
+ cells.add(`${i},${j}`);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * This function gives the occupied area of the routes (B7 of MEAS0): `moa = cells that S touches / cells that the straight lines touch`.
202
+ * The cells are squares of side `unit` from the origin, and `unit` is `u` of the board (`boardUnit`). The straight line of an edge joins the two ends of its route.
203
+ * The scale is a ratio, and lower is better. Routes without a straight line give 1. The cost is linear in the cells.
204
+ */
205
+ export function occupiedArea(routes: readonly Route[], unit: number): number {
206
+ const u = unit > 0 ? unit : 1;
207
+ const drawn = new Set<string>();
208
+ const straight = new Set<string>();
209
+ for (const r of routes) {
210
+ const p = r.points;
211
+ if (p.length < 2) continue;
212
+ for (let i = 1; i < p.length; i++) touchCells(p[i - 1]!, p[i]!, u, drawn);
213
+ touchCells(p[0]!, p[p.length - 1]!, u, straight);
214
+ }
215
+ return straight.size > 0 ? drawn.size / straight.size : 1;
216
+ }
217
+
218
+ /** The run profile of a result (B8 of MEAS0). */
219
+ export interface RunProfile {
220
+ /** The number of shared segments: the runs and the trunks of the bundles with two edges or more. */
221
+ readonly runs: number;
222
+ /** The median of the edges of each shared segment. It is 0 without a shared segment. */
223
+ readonly median: number;
224
+ /** The most edges of one shared segment. It is 0 without a shared segment. */
225
+ readonly max: number;
226
+ }
227
+
228
+ /** This function gives the run profile of a result (B8 of MEAS0): the shared segments, and the median and the most edges of each. The cost is linear. */
229
+ export function runProfile(graph: SegmentGraph): RunProfile {
230
+ const counts = graph.segments.filter((s) => s.count > 1).map((s) => s.count);
231
+ if (counts.length === 0) return { runs: 0, median: 0, max: 0 };
232
+ return { runs: counts.length, median: quantile(counts, 0.5), max: Math.max(...counts) };
233
+ }
234
+
235
+ /** The lane fidelity of a result (B9 of MEAS0). */
236
+ export interface LaneFidelity {
237
+ /** The median of `lf(e)` over the edges with a shared segment. */
238
+ readonly median: number;
239
+ /** The largest `lf(e)`. */
240
+ readonly max: number;
241
+ /** The number of edges with a shared segment. */
242
+ readonly edges: number;
243
+ }
244
+
245
+ /**
246
+ * This function gives the lane fidelity (B9 of MEAS0): `lf(e) = frechet(lane(e), r_e) / (width · k / 2)` for each edge `e` with a shared segment.
247
+ * `lane(e)` is the lane route of `openLanes`, `r_e` is the route, and `k` is the edge count of the widest shared segment of `e`.
248
+ * `frechet` is the discrete Fréchet distance of the points. A value of 1 or less is correct, and lower is better.
249
+ * It gives `undefined` when no edge has a shared segment. The cost is quadratic in the points of each route.
250
+ */
251
+ export function laneFidelity(routes: readonly Route[], open: OpenLanes): LaneFidelity | undefined {
252
+ const count = new Map(open.graph.segments.map((s) => [s.id, s.count] as const));
253
+ const lanes = new Map(open.lanes.map((r) => [r.edge, r.points] as const));
254
+ const own = new Map(routes.map((r) => [r.edge, r.points] as const));
255
+ const values: number[] = [];
256
+ for (const path of open.graph.paths) {
257
+ let k = 0;
258
+ for (const ref of path.segments) k = Math.max(k, count.get(ref.segment) ?? 0);
259
+ const lane = lanes.get(path.edge);
260
+ const route = own.get(path.edge);
261
+ if (k < 2 || !lane || !route || open.width <= 0) continue;
262
+ values.push(frechet(lane, route) / ((open.width * k) / 2));
263
+ }
264
+ if (values.length === 0) return undefined;
265
+ return { median: quantile(values, 0.5), max: Math.max(...values), edges: values.length };
266
+ }
267
+
268
+ /**
269
+ * This function counts the lane routes that go through a box that is not one of their ends (M4 of MEAS0).
270
+ * It is the count `laneThrough` of the room bench (`measureBoard`), for the lanes of any result. Lower is better. The cost is `n log n`.
271
+ */
272
+ export function lanesThrough(graph: Graph, open: OpenLanes): number {
273
+ return measureRouteFaults(graph, { routes: open.lanes }).through;
274
+ }
275
+
276
+ /**
277
+ * This function gives the area of a result (M1 of MEAS0) for a router: the growth of the board that the open lanes need.
278
+ * The value is the area of the bounding box of the boxes and the lane routes. The function divides it by the area of the bounding box of the boxes.
279
+ *
280
+ * It is the room form `area(open) / area(packed)` with the boxes as they are. The scale is a ratio of 1 or more, and lower is better.
281
+ * A board without area gives 1. The cost is linear.
282
+ */
283
+ export function laneArea(graph: Graph, open: OpenLanes): number {
284
+ let x0 = Number.POSITIVE_INFINITY;
285
+ let y0 = Number.POSITIVE_INFINITY;
286
+ let x1 = Number.NEGATIVE_INFINITY;
287
+ let y1 = Number.NEGATIVE_INFINITY;
288
+ for (const n of graph.nodes) {
289
+ x0 = Math.min(x0, n.box.x);
290
+ y0 = Math.min(y0, n.box.y);
291
+ x1 = Math.max(x1, n.box.x + n.box.w);
292
+ y1 = Math.max(y1, n.box.y + n.box.h);
293
+ }
294
+ const packed = (x1 - x0) * (y1 - y0);
295
+ if (!(packed > 0)) return 1;
296
+ for (const r of open.lanes)
297
+ for (const p of r.points) {
298
+ x0 = Math.min(x0, p.x);
299
+ y0 = Math.min(y0, p.y);
300
+ x1 = Math.max(x1, p.x);
301
+ y1 = Math.max(y1, p.y);
302
+ }
303
+ return ((x1 - x0) * (y1 - y0)) / packed;
304
+ }
@@ -1,5 +1,5 @@
1
- import { center, dist } from "../../geom.ts";
2
- import type { Graph, Level } from "../../model.ts";
1
+ import { center, dist, pointBoxDistance, polylineLength, type Box, type Point } from "../../geom.ts";
2
+ import type { Graph, Level, Route } from "../../model.ts";
3
3
  import { kendallTau } from "./stats.ts";
4
4
 
5
5
  /** This function gives the levels in the order of their depth, from the far view to the near view. */
@@ -101,3 +101,87 @@ export function orderStability(graph: Graph, levels: readonly Level[]): number {
101
101
  }
102
102
  return n ? sum / n : 1;
103
103
  }
104
+
105
+ /** This function gives the points of a polyline at most `step` apart: each vertex, and evenly spaced points on each longer segment. */
106
+ function samplePolyline(p: readonly Point[], step: number): Point[] {
107
+ const out: Point[] = [];
108
+ for (let i = 0; i < p.length; i++) {
109
+ const b = p[i]!;
110
+ if (i > 0) {
111
+ const a = p[i - 1]!;
112
+ const n = Math.ceil(dist(a, b) / step);
113
+ for (let k = 1; k < n; k++) out.push({ x: a.x + ((b.x - a.x) * k) / n, y: a.y + ((b.y - a.y) * k) / n });
114
+ }
115
+ out.push(b);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** This function gives the Hausdorff distance between two sets of points. Both sets have one point or more. */
121
+ function hausdorff(a: readonly Point[], b: readonly Point[]): number {
122
+ const directed = (p: readonly Point[], q: readonly Point[]): number => {
123
+ let worst = 0;
124
+ for (const x of p) {
125
+ let best = Number.POSITIVE_INFINITY;
126
+ for (const y of q) best = Math.min(best, dist(x, y));
127
+ worst = Math.max(worst, best);
128
+ }
129
+ return worst;
130
+ };
131
+ return Math.max(directed(a, b), directed(b, a));
132
+ }
133
+
134
+ /** The number of points of the sample of one polyline for the link agreement, at most. */
135
+ const AGREEMENT_SAMPLES = 64;
136
+
137
+ /**
138
+ * This function gives the link agreement of the levels with groups (Z9 of MEAS0).
139
+ * `la = mean over links of hausdorff(link, routes of its edges outside the two items) / u`, with `u` the argument `unit`.
140
+ * The edges of a link are the edges between a node of one item and a node of the other item, in either direction.
141
+ *
142
+ * The function samples each polyline at points `u / 4` apart, with at most 64 points for each polyline. It keeps the points outside the boxes of the two items.
143
+ * A link without such points on both sides does not count. It gives `undefined` when no link counts.
144
+ * The value has no unit, and lower is better. The cost is quadratic in the points of each link.
145
+ */
146
+ export function linkAgreement(graph: Graph, levels: readonly Level[], routes: readonly Route[], unit: number): number | undefined {
147
+ const u = unit > 0 ? unit : 1;
148
+ const endsOf = new Map(graph.edges.map((e) => [e.id, [e.source.node, e.target.node] as const]));
149
+ const sample = (p: readonly Point[], boxes: readonly Box[]): Point[] => {
150
+ const step = Math.max(u / 4, polylineLength(p) / AGREEMENT_SAMPLES);
151
+ return samplePolyline(p, step).filter((q) => boxes.every((b) => pointBoxDistance(q, b) > 0));
152
+ };
153
+ let sum = 0;
154
+ let n = 0;
155
+ for (const level of levels) {
156
+ if (!level.items.some((i) => i.nodes.length > 1)) continue;
157
+ const itemOf = itemIndex(level);
158
+ const indexOf = new Map(level.items.map((it, i) => [it.id, i] as const));
159
+ // The edges between each pair of items, by the key of the pair.
160
+ const between = new Map<string, Route[]>();
161
+ for (const r of routes) {
162
+ const ends = endsOf.get(r.edge);
163
+ if (!ends) continue;
164
+ const i = itemOf.get(ends[0]);
165
+ const j = itemOf.get(ends[1]);
166
+ if (i === undefined || j === undefined || i === j) continue;
167
+ const key = i < j ? `${i},${j}` : `${j},${i}`;
168
+ const list = between.get(key);
169
+ if (list) list.push(r);
170
+ else between.set(key, [r]);
171
+ }
172
+ for (const link of level.links) {
173
+ const i = indexOf.get(link.source);
174
+ const j = indexOf.get(link.target);
175
+ if (i === undefined || j === undefined || i === j) continue;
176
+ const mine = between.get(i < j ? `${i},${j}` : `${j},${i}`);
177
+ if (!mine) continue;
178
+ const boxes = [level.items[i]!.box, level.items[j]!.box];
179
+ const a = sample(link.points, boxes);
180
+ const b = mine.flatMap((r) => sample(r.points, boxes));
181
+ if (a.length === 0 || b.length === 0) continue;
182
+ sum += hausdorff(a, b) / u;
183
+ n++;
184
+ }
185
+ }
186
+ return n > 0 ? sum / n : undefined;
187
+ }
@@ -0,0 +1,3 @@
1
+ // The activation test of a screen (ACTIVATION): did the idea of a candidate run on the input?
2
+ // `outputHash`, `outputGroups` and `exercised` come from `@mark1russell7/layout-contract` (PORTFOLIO-FIT). `docs/PROMOTION.md` gives the rules.
3
+ export { exercised, outputGroups, outputHash, type ExerciseEvidence, type HashOptions, type OutputGroup } from "@mark1russell7/layout-contract";
@@ -0,0 +1,140 @@
1
+ import { quantile } from "../measures/stats.ts";
2
+ import { GAP_BASELINES, type ExactOptimum, type GapRule } from "./gap.ts";
3
+
4
+ /**
5
+ * The interval of the optimum B1 of one solve (EXACT-BOUNDS). The low end is the B1 of the best solution of HiGHS.
6
+ * The high end comes from the dual bound. For a proven optimum, the two ends are equal.
7
+ */
8
+ export interface OptimumInterval {
9
+ readonly lo: number;
10
+ readonly hi: number;
11
+ readonly proven: boolean;
12
+ }
13
+
14
+ /**
15
+ * This function gives the interval of the optimum B1 of a solve, or `undefined` without a solution.
16
+ * The high end is `1 - (1 - b1) × bound / objective`. It supposes that the ink of the optimum is at least `bound / objective` times the ink of the best solution,
17
+ * with routes of the same length. The objective of the model is the ink plus a small cost of the length, so the ratio is a ratio of the ink.
18
+ * Without a positive bound, the high end is 1.
19
+ */
20
+ export function optimumInterval(o: ExactOptimum): OptimumInterval | undefined {
21
+ if (o.b1 === null || o.objective === null) return undefined;
22
+ if (o.proven) return { lo: o.b1, hi: o.b1, proven: true };
23
+ const ratio = o.bound !== null && o.bound > 0 && o.objective > 0 ? Math.min(1, o.bound / o.objective) : 0;
24
+ return { lo: o.b1, hi: Math.min(1, Math.max(o.b1, 1 - (1 - o.b1) * ratio)), proven: false };
25
+ }
26
+
27
+ /** The interval of the gap closed of one run against an interval of the optimum. */
28
+ export interface GapInterval {
29
+ readonly lo: number;
30
+ readonly hi: number;
31
+ readonly mid: number;
32
+ readonly width: number;
33
+ }
34
+
35
+ /**
36
+ * This function gives the interval of the gap closed `(b1 - base) / (optimum - base)` over the interval of the optimum.
37
+ * For a run above the baseline, the high end of the optimum gives the low end of the gap closed. The low end of the optimum gives the high end.
38
+ * It gives `undefined` when the low end of the optimum is at the baseline or below it, because then the gap closed has no limit.
39
+ */
40
+ export function gapInterval(b1: number, base: number, optimum: { readonly lo: number; readonly hi: number }): GapInterval | undefined {
41
+ const dLo = optimum.lo - base;
42
+ const dHi = optimum.hi - base;
43
+ if (!(dLo > 1e-9)) return undefined;
44
+ const a = (b1 - base) / dHi;
45
+ const b = (b1 - base) / dLo;
46
+ const lo = Math.min(a, b);
47
+ const hi = Math.max(a, b);
48
+ return { lo, hi, mid: (lo + hi) / 2, width: hi - lo };
49
+ }
50
+
51
+ /** The values of one run that the bounds read: B1, the largest stretch of the model and the flag of mixed collections. */
52
+ export interface BoundsRun {
53
+ readonly b1: number;
54
+ readonly stretch: number;
55
+ readonly mixed: boolean;
56
+ }
57
+
58
+ /** The gap closed of one strategy at one size, one rule and one cap, over the inputs. */
59
+ export interface BoundsRow {
60
+ readonly strategy: string;
61
+ /** The number of inputs, and the number of inputs where the run has a value. */
62
+ readonly inputs: number;
63
+ readonly values: number;
64
+ /** The medians over the inputs with a value: the midpoint, the low end, the high end and the width of the interval. */
65
+ readonly mid: number;
66
+ readonly lo: number;
67
+ readonly hi: number;
68
+ readonly width: number;
69
+ }
70
+
71
+ /** The gap closed of each strategy at one size, one rule and one cap, sorted by the median of the midpoint. */
72
+ export interface BoundsCell {
73
+ readonly nodes: number;
74
+ readonly rule: GapRule;
75
+ readonly cap: number;
76
+ /** The counts of the solves: the inputs, the proven optima, the solves with a bound only, and the solves without a solution. */
77
+ readonly inputs: number;
78
+ readonly proven: number;
79
+ readonly bounded: number;
80
+ readonly none: number;
81
+ /** The median width of the interval of the optimum B1, over the solves with a solution. */
82
+ readonly optimumWidth: number;
83
+ readonly rows: readonly BoundsRow[];
84
+ }
85
+
86
+ /**
87
+ * This function gives the gap closed of each strategy for each size, rule and cap of the optima.
88
+ * A run has a value when it obeys the rule and when its stretch is at or below the cap. The solve must also have a solution.
89
+ * The rule `collection` refuses a run with a shared segment that holds edges of two collections.
90
+ * The baseline of each rule is the run of `GAP_BASELINES` on the same input, with the prefix `hw:` for a highway strategy.
91
+ * The rows sort by the median of the midpoint, from high to low. A strategy with a value on fewer than half of the inputs comes after the others.
92
+ */
93
+ export function boundsCells(runs: ReadonlyMap<string, ReadonlyMap<string, BoundsRun>>, optima: readonly ExactOptimum[]): BoundsCell[] {
94
+ const keys = [...new Set(optima.map((o) => `${o.nodes}|${o.rule}|${o.cap}`))];
95
+ const cells: BoundsCell[] = [];
96
+ const baseName = (rule: GapRule): string => (rule === "all" ? `hw:${GAP_BASELINES.all}` : GAP_BASELINES.collection);
97
+ for (const key of keys) {
98
+ const [n, rule, c] = key.split("|") as [string, GapRule, string];
99
+ const nodes = Number(n);
100
+ const cap = Number(c);
101
+ const mine = optima.filter((o) => o.nodes === nodes && o.rule === rule && o.cap === cap && runs.has(o.input));
102
+ const intervals = new Map(mine.flatMap((o) => (optimumInterval(o) ? [[o.input, optimumInterval(o)!] as const] : [])));
103
+ const names = [...new Set(mine.flatMap((o) => [...runs.get(o.input)!.keys()]))].sort();
104
+ const rows: BoundsRow[] = names.map((strategy) => {
105
+ const vs: GapInterval[] = [];
106
+ for (const o of mine) {
107
+ const row = runs.get(o.input)!;
108
+ const r = row.get(strategy);
109
+ const base = row.get(baseName(rule));
110
+ const opt = intervals.get(o.input);
111
+ if (r === undefined || base === undefined || opt === undefined) continue;
112
+ if ((rule === "collection" && r.mixed) || r.stretch > cap + 1e-9) continue;
113
+ const g = gapInterval(r.b1, base.b1, opt);
114
+ if (g !== undefined) vs.push(g);
115
+ }
116
+ const med = (f: (g: GapInterval) => number): number => quantile(vs.map(f), 0.5);
117
+ return { strategy, inputs: mine.length, values: vs.length, mid: med((g) => g.mid), lo: med((g) => g.lo), hi: med((g) => g.hi), width: med((g) => g.width) };
118
+ });
119
+ const few = (r: BoundsRow): number => (2 * r.values < r.inputs || r.values === 0 ? 1 : 0);
120
+ rows.sort((a, b) => few(a) - few(b) || (Number.isNaN(b.mid) ? -Infinity : b.mid) - (Number.isNaN(a.mid) ? -Infinity : a.mid) || (a.strategy < b.strategy ? -1 : 1));
121
+ const widths = [...intervals.values()].map((i) => i.hi - i.lo);
122
+ cells.push({
123
+ nodes,
124
+ rule,
125
+ cap,
126
+ inputs: mine.length,
127
+ proven: mine.filter((o) => o.proven).length,
128
+ bounded: mine.filter((o) => !o.proven && intervals.has(o.input)).length,
129
+ none: mine.filter((o) => !intervals.has(o.input)).length,
130
+ optimumWidth: quantile(widths, 0.5),
131
+ rows,
132
+ });
133
+ }
134
+ return cells.sort((a, b) => a.nodes - b.nodes || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0) || a.cap - b.cap);
135
+ }
136
+
137
+ /** This function gives the strategies that rank in a cell: the first `k` rows with a value on at least half of the inputs. */
138
+ export function topOf(cell: BoundsCell, k = 5): BoundsRow[] {
139
+ return cell.rows.filter((r) => r.values > 0 && 2 * r.values >= r.inputs).slice(0, k);
140
+ }