@mark1russell7/highways 0.5.0 → 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 (56) hide show
  1. package/README.md +43 -2
  2. package/build/index.d.ts +1472 -63
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +10007 -3406
  5. package/build/index.js.map +1 -1
  6. package/package.json +11 -2
  7. package/src/bench/exact/index.ts +72 -11
  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/screen/activation.ts +3 -0
  12. package/src/bench/screen/bounds.ts +140 -0
  13. package/src/bench/screen/compare.ts +26 -0
  14. package/src/bench/screen/exact-set.ts +100 -0
  15. package/src/bench/screen/exercise.ts +290 -0
  16. package/src/bench/screen/forms.ts +14 -0
  17. package/src/bench/screen/gap.ts +316 -0
  18. package/src/bench/screen/hash.ts +7 -76
  19. package/src/bench/screen/index.ts +6 -0
  20. package/src/bench/screen/measures.ts +71 -1
  21. package/src/bench/screen/portfolio-pick.ts +20 -0
  22. package/src/bench/screen/screen.ts +50 -2
  23. package/src/bench/screen/set.ts +3 -2
  24. package/src/highways/areas/index.ts +160 -0
  25. package/src/highways/confluent/index.ts +154 -0
  26. package/src/highways/confluent/k23.test-util.ts +8 -0
  27. package/src/highways/consensus/index.ts +129 -0
  28. package/src/highways/crossbars/index.ts +184 -0
  29. package/src/highways/cubu/index.ts +26 -0
  30. package/src/highways/divided/index.ts +111 -0
  31. package/src/highways/fdeb/index.ts +193 -0
  32. package/src/highways/frechet/index.ts +232 -0
  33. package/src/highways/hierarchy/index.ts +140 -0
  34. package/src/highways/index.ts +64 -0
  35. package/src/highways/kdeeb/index.ts +355 -0
  36. package/src/highways/link-communities/index.ts +189 -0
  37. package/src/highways/mesh/index.ts +209 -0
  38. package/src/highways/mlseb/index.ts +204 -0
  39. package/src/highways/p-hub/index.ts +248 -0
  40. package/src/highways/power/index.ts +117 -0
  41. package/src/highways/resistance/index.ts +159 -0
  42. package/src/highways/sbeb/index.ts +303 -0
  43. package/src/highways/scale-space/index.ts +135 -0
  44. package/src/highways/spectral/index.ts +391 -0
  45. package/src/highways/territories/index.ts +150 -0
  46. package/src/highways/trace.ts +153 -0
  47. package/src/index.ts +1 -0
  48. package/src/portfolio/bands.ts +33 -0
  49. package/src/portfolio/fit.ts +29 -0
  50. package/src/portfolio/index.ts +6 -0
  51. package/src/portfolio/key.ts +98 -0
  52. package/src/portfolio/members.ts +171 -0
  53. package/src/portfolio/router.ts +133 -0
  54. package/src/portfolio/sets.ts +28 -0
  55. package/src/strategies/builtins.ts +222 -1
  56. package/src/strategies/schemas.ts +280 -1
@@ -0,0 +1,290 @@
1
+ // The activation test of the screen of `highways` (ACTIVATION): the highways form of an output, the own move of each strategy,
2
+ // the base of each strategy, and the report of the candidates that are not exercised.
3
+ // `activation.ts` holds the functions without the types of `highways`. This file holds the parts of `highways`.
4
+ import type { CurveSegment } from "../../curves/model.ts";
5
+ import type { HighwayPlan } from "../../highways/model.ts";
6
+ import type { Graph, Result } from "../../model.ts";
7
+ import { highwayStrategies } from "../../strategies/builtins.ts";
8
+ import type { Strategy } from "../../strategies/model.ts";
9
+ import { exercised, outputGroups, outputHash, type OutputGroup } from "./activation.ts";
10
+ import type { LayoutRecord } from "./forms.ts";
11
+ import { RECORD_EPS } from "./hash.ts";
12
+
13
+ const byEdge = <T extends { readonly edge: string }>(list: readonly T[]): T[] => [...list].sort((a, b) => (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : 0));
14
+
15
+ /**
16
+ * This function gives the output hash of a result of a router, in the highways form.
17
+ * The form holds the routes in the order of the edge ids, and the levels, the bundles and the runs as the router gives them.
18
+ * Each number goes to the grid `RECORD_EPS`, as `round3` does.
19
+ */
20
+ export function resultHash(result: Result): string {
21
+ return outputHash(resultForm(result), { grid: RECORD_EPS });
22
+ }
23
+
24
+ /** This function gives the highways form of a result of a router: the value that `resultHash` hashes. */
25
+ export function resultForm(result: Result): object {
26
+ return { routes: byEdge(result.routes), levels: result.levels, bundles: result.bundles, runs: result.runs };
27
+ }
28
+
29
+ /** This function gives the output hash of the curves of the step `curve`, in the order of the edge ids. */
30
+ export function curvesHash(edges: readonly string[], curves: readonly (readonly CurveSegment[])[]): string {
31
+ return outputHash(byEdge(edges.map((edge, i) => ({ edge, segments: curves[i] ?? [] }))), { grid: RECORD_EPS });
32
+ }
33
+
34
+ /**
35
+ * The name of the own move of each built-in highway finder. The trace of a record names the count `move.<name>`.
36
+ * The count is the number of highways of the plan with 2 edges or more. A finder without an entry counts `move.highway`.
37
+ */
38
+ export const MOVE_NAMES: Readonly<Record<string, string>> = Object.freeze({
39
+ keys: "trunk",
40
+ "shared-ways": "sharedWay",
41
+ "shared-ways-all": "sharedWay",
42
+ corridors: "corridor",
43
+ quickbundles: "bundle",
44
+ "road-classes": "mainRoad",
45
+ "flow-field": "fieldLine",
46
+ wspd: "pair",
47
+ "od-cells": "cellPair",
48
+ select: "pick",
49
+ mingle: "merge",
50
+ spiral: "spiralTree",
51
+ "flow-clusters": "cluster",
52
+ traclus: "cluster",
53
+ diffusion: "channel",
54
+ nested: "cluster",
55
+ growth: "road",
56
+ "edge-paths": "path",
57
+ "soft-keys": "softKey",
58
+ hough: "line",
59
+ ridges: "ridge",
60
+ hdbscan: "cluster",
61
+ "hub-arcs": "hubArc",
62
+ hierarchy: "bundle",
63
+ fdeb: "bundle",
64
+ mesh: "meshRoad",
65
+ kdeeb: "bundle",
66
+ cubu: "bundle",
67
+ mlseb: "bundle",
68
+ sbeb: "bundle",
69
+ areas: "area",
70
+ confluent: "track",
71
+ power: "powerEdge",
72
+ frechet: "cluster",
73
+ spectral: "cluster",
74
+ resistance: "cluster",
75
+ "link-communities": "community",
76
+ territories: "territory",
77
+ consensus: "cluster",
78
+ "p-hub": "hubLink",
79
+ "scale-space": "cluster",
80
+ });
81
+
82
+ /**
83
+ * The highway strategies that change the plan of another strategy (`from`). Their own move is a change of that plan, so they count no move.
84
+ * The output against the plan `from` tells if they ran.
85
+ */
86
+ export const PLAN_CHANGERS: readonly string[] = Object.freeze(["crossbars", "divided", "lanes", "level-links", "meet", "stubs", "weiszfeld", "zipper"]);
87
+
88
+ const FINDERS: readonly string[] = [...new Set([...Object.keys(highwayStrategies).filter((n) => !n.includes("+")), "shared-ways-all"])].sort((a, b) => b.length - a.length);
89
+
90
+ /** This function gives the name of a strategy without the prefix of a module, for example `spectral-c0.3` for `w3f:spectral-c0.3`. */
91
+ function localName(name: string): string {
92
+ const colon = name.indexOf(":");
93
+ const local = colon >= 0 ? name.slice(colon + 1) : name;
94
+ // A wave can give its own variant a name with the wave, for example `w3f-nested-l1`.
95
+ return local.replace(/^w\d[a-z]+-/, "");
96
+ }
97
+
98
+ /**
99
+ * This function gives the built-in highway strategy of a name: the strategy itself, or the strategy of a variant, for example `power` for `power-near`.
100
+ * It gives `undefined` for a gate (a name with `+`) and for a name of another step.
101
+ */
102
+ export function finderOf(name: string): string | undefined {
103
+ const local = localName(name);
104
+ if (local.includes("+")) return undefined;
105
+ return FINDERS.find((f) => local === f || local.startsWith(`${f}-`));
106
+ }
107
+
108
+ /**
109
+ * This function gives the count of the own move of a highway strategy on its plan, for example `{ "move.powerEdge": 3 }`.
110
+ * It gives `undefined` for a gate and for a strategy of `PLAN_CHANGERS`.
111
+ */
112
+ export function planMoves(name: string, plan: HighwayPlan): Record<string, number> | undefined {
113
+ const finder = finderOf(name);
114
+ if (finder === undefined || PLAN_CHANGERS.includes(finder)) return undefined;
115
+ return { [`move.${MOVE_NAMES[finder] ?? "highway"}`]: plan.highways.filter((h) => h.members.length >= 2).length };
116
+ }
117
+
118
+ /** A function that gives the own move counts of a strategy on a graph, or `undefined` for a strategy without a count. */
119
+ export type MoveCounter = (strategy: string, graph: Graph) => Readonly<Record<string, number>> | undefined;
120
+
121
+ /** This function gives a `MoveCounter` for highway strategies, by the names of the records. It makes the plan of the strategy again. */
122
+ export function highwayMoves(strategies: Readonly<Record<string, Strategy<"highway", object>>>): MoveCounter {
123
+ return (name, graph) => {
124
+ const s = strategies[name];
125
+ return s === undefined ? undefined : planMoves(name, s.run({ graph }, s.defaults));
126
+ };
127
+ }
128
+
129
+ /**
130
+ * This function gives the base of a strategy for the fallback test: the strategy that it falls back to or extends.
131
+ * - A gate `X+G` and a wrapped router `X+W` have the base `X`.
132
+ * - A highway finder of a module (`hw:power`, `w3e:power-near`) has the base `hw:none`, the empty plan.
133
+ * - A plan changer has the base of its plan `from`: `hw:shared-ways`, or `hw:corridors` and `hw:none` for a name that ends with `-corridors` and `-none`.
134
+ * - An option of a router (`shared-cap-3`, `shared-all-p3`, `trunks-tree-bf02`, `corners-side`) has the base `shared`, `shared-all`, `trunks` or `corners`.
135
+ * The function gives `undefined` when the base is not in `known`.
136
+ */
137
+ export function activationBase(name: string, known: ReadonlySet<string>): string | undefined {
138
+ const pick = (...names: string[]): string | undefined => names.find((n) => n !== name && known.has(n));
139
+ const colon = name.indexOf(":");
140
+ if (colon >= 0) {
141
+ const prefix = name.slice(0, colon);
142
+ const local = name.slice(colon + 1);
143
+ if (local.includes("+")) {
144
+ const inner = local.slice(0, local.lastIndexOf("+"));
145
+ return pick(`${prefix}:${inner}`, `hw:${inner}`);
146
+ }
147
+ if (local === "none") return undefined;
148
+ const finder = finderOf(name);
149
+ if (finder !== undefined && PLAN_CHANGERS.includes(finder)) {
150
+ const from = local.endsWith("-corridors") ? "corridors" : local.endsWith("-none") ? "none" : "shared-ways";
151
+ return pick(`hw:${from}`);
152
+ }
153
+ return pick("hw:none");
154
+ }
155
+ if (name.includes("+")) return pick(name.slice(0, name.lastIndexOf("+")));
156
+ for (const router of ["shared-all", "shared", "trunks", "corners"]) if (name.startsWith(`${router}-`)) return pick(router);
157
+ return undefined;
158
+ }
159
+
160
+ /** The activation of one strategy over the decision inputs of a screen. */
161
+ export interface ActivationRow {
162
+ readonly strategy: string;
163
+ /** The base of the fallback test, or `undefined` when the strategy has none. */
164
+ readonly base?: string;
165
+ /** The decision inputs where the strategy ran. */
166
+ readonly inputs: number;
167
+ /** The decision inputs where a move count is above 0. */
168
+ readonly moved: readonly string[];
169
+ /** The decision inputs where the output differs from the output of the base. */
170
+ readonly differs: readonly string[];
171
+ /** The sum of each move count over the decision inputs. */
172
+ readonly moves: Readonly<Record<string, number>>;
173
+ readonly verdict: "exercised" | "not-exercised";
174
+ /** The id of the output group of the strategy on the decision inputs. */
175
+ readonly group: string;
176
+ }
177
+
178
+ /** The groups of the strategies with equal output on each input of one cell (a family of boards). They are diagnostics only. */
179
+ export interface CellGroups {
180
+ readonly cell: string;
181
+ readonly groups: readonly OutputGroup[];
182
+ }
183
+
184
+ /** The activation report of a screen. */
185
+ export interface ActivationReport {
186
+ /** The decision inputs, in sorted order. */
187
+ readonly inputs: readonly string[];
188
+ readonly rows: readonly ActivationRow[];
189
+ /** The output groups with more than one strategy: equal output hashes on each decision input. */
190
+ readonly groups: readonly OutputGroup[];
191
+ /** The cell groups with more than one strategy, for each family of the decision inputs. */
192
+ readonly cells: readonly CellGroups[];
193
+ }
194
+
195
+ /** This function gives the family of an input id, for example `small-clustered` for `screen-v1/small-clustered/s0`. */
196
+ export function familyOf(input: string): string {
197
+ const parts = input.split("/");
198
+ return parts.length >= 3 ? parts[parts.length - 2]! : input;
199
+ }
200
+
201
+ /** This function gives the move counts of a record, from its trace. */
202
+ export function recordMoves(record: LayoutRecord): Record<string, number> {
203
+ const out: Record<string, number> = {};
204
+ for (const t of record.trace ?? []) for (const [k, v] of Object.entries(t.measures ?? {})) if (k.startsWith("move.")) out[k] = (out[k] ?? 0) + v;
205
+ return out;
206
+ }
207
+
208
+ /**
209
+ * This function gives the activation report of the records of a screen.
210
+ * A strategy is exercised when a move count is above 0 on a decision input.
211
+ * It is also exercised when its output differs from the output of its base on a decision input.
212
+ * The decision inputs are the inputs of the families `families`, or each input. `bases` gives the base of each strategy, or `activationBase` gives it.
213
+ */
214
+ export function activationReport(
215
+ records: readonly LayoutRecord[],
216
+ options: { readonly families?: readonly string[]; readonly bases?: Readonly<Record<string, string>> } = {},
217
+ ): ActivationReport {
218
+ const decision = records.filter((r) => options.families?.includes(familyOf(r.input)) ?? true);
219
+ const inputs = [...new Set(decision.map((r) => r.input))].sort();
220
+ const names = [...new Set(decision.map((r) => r.strategy))];
221
+ const known = new Set(names);
222
+ const at = new Map(decision.map((r) => [`${r.strategy}\u0000${r.input}`, r]));
223
+ const hashOf = (strategy: string, input: string): string | undefined => {
224
+ const r = at.get(`${strategy}\u0000${input}`);
225
+ return r === undefined || r.skipped !== undefined ? undefined : r.outputHash;
226
+ };
227
+ const list = (strategy: string, of: readonly string[]): string[] => of.map((i) => hashOf(strategy, i) ?? "-");
228
+ const all = outputGroups(Object.fromEntries(names.map((n) => [n, list(n, inputs)])));
229
+ const groupOf = new Map(all.flatMap((g) => g.members.map((m) => [m, g.id] as const)));
230
+ const rows = names.map((strategy): ActivationRow => {
231
+ const base = options.bases?.[strategy] ?? activationBase(strategy, known);
232
+ const moved: string[] = [];
233
+ const differs: string[] = [];
234
+ const moves: Record<string, number> = {};
235
+ let ran = 0;
236
+ for (const input of inputs) {
237
+ const r = at.get(`${strategy}\u0000${input}`);
238
+ if (r === undefined || r.skipped !== undefined) continue;
239
+ ran++;
240
+ const m = recordMoves(r);
241
+ for (const [k, v] of Object.entries(m)) moves[k] = (moves[k] ?? 0) + v;
242
+ if (exercised({ moves: m })) moved.push(input);
243
+ const own = hashOf(strategy, input);
244
+ const theirs = base === undefined ? undefined : hashOf(base, input);
245
+ // A skipped run of the base has no hash. Then the input gives no evidence of a difference.
246
+ if (own !== undefined && theirs !== undefined && exercised({ hashes: [own], baseHashes: [theirs] })) differs.push(input);
247
+ }
248
+ const verdict = moved.length > 0 || differs.length > 0 ? "exercised" : "not-exercised";
249
+ return { strategy, ...(base === undefined ? {} : { base }), inputs: ran, moved, differs, moves, verdict, group: groupOf.get(strategy) ?? strategy };
250
+ });
251
+ const families = [...new Set(inputs.map(familyOf))].sort();
252
+ const cells = families.map((cell) => {
253
+ const of = inputs.filter((i) => familyOf(i) === cell);
254
+ return { cell, groups: outputGroups(Object.fromEntries(names.map((n) => [n, list(n, of)]))).filter((g) => g.members.length > 1) };
255
+ });
256
+ return { inputs, rows, groups: all.filter((g) => g.members.length > 1), cells };
257
+ }
258
+
259
+ /** This function writes the activation report as Markdown: the strategies that are not exercised, and the output groups. */
260
+ export function formatActivation(report: ActivationReport, candidates?: ReadonlySet<string>): string {
261
+ const rows = report.rows.filter((r) => candidates?.has(r.strategy) ?? true);
262
+ const silent = rows.filter((r) => r.verdict === "not-exercised");
263
+ const lines = [
264
+ "## Activation",
265
+ "",
266
+ `${report.inputs.length} decision inputs, ${rows.length} strategies. A strategy is exercised when its own move count is above 0, or its output differs from the output of its base, on one decision input or more.`,
267
+ "",
268
+ `### Not exercised (${silent.length})`,
269
+ "",
270
+ "| strategy | base | runs | moves |",
271
+ "|---|---|---:|---|",
272
+ ...silent.map((r) => `| ${r.strategy} | ${r.base ?? "-"} | ${r.inputs} | ${Object.entries(r.moves).map(([k, v]) => `${k} ${v}`).join(", ") || "-"} |`),
273
+ "",
274
+ `### Output groups (${report.groups.length})`,
275
+ "",
276
+ "The strategies of a group have equal output on each decision input. A group counts as one candidate.",
277
+ "",
278
+ ...report.groups.map((g) => `- ${g.members.join(", ")}`),
279
+ "",
280
+ "### All strategies",
281
+ "",
282
+ "| strategy | base | runs | moved | differs | moves | verdict | group |",
283
+ "|---|---|---:|---:|---:|---|---|---|",
284
+ ...rows.map(
285
+ (r) =>
286
+ `| ${r.strategy} | ${r.base ?? "-"} | ${r.inputs} | ${r.moved.length} | ${r.differs.length} | ${Object.entries(r.moves).map(([k, v]) => `${k} ${v}`).join(", ") || "-"} | ${r.verdict} | ${r.group} |`,
287
+ ),
288
+ ];
289
+ return lines.join("\n") + "\n";
290
+ }
@@ -186,6 +186,20 @@ export interface LayoutRecord {
186
186
  readonly commit?: string;
187
187
  /** The reason when the strategy did not run, for example an input over its `maxNodes`. Then the record has no measures. */
188
188
  readonly skipped?: string;
189
+ /**
190
+ * The first 16 hex characters of the SHA-256 of the output in the highways form (ACTIVATION). `resultHash` gives it for a router.
191
+ * Two records with the same hash have the same output, at the precision `RECORD_EPS`.
192
+ */
193
+ readonly outputHash?: string;
194
+ /** The facts of the stages of the run. The trace measures `move.<name>` count the own move of the strategy (ACTIVATION). */
195
+ readonly trace?: readonly RecordTrace[];
196
+ }
197
+
198
+ /** The facts of one stage of a run, as the `StageTrace` of the contract: its id, its time and its trace measures. */
199
+ export interface RecordTrace {
200
+ readonly id: string;
201
+ readonly ms?: number;
202
+ readonly measures?: Readonly<Record<string, number>>;
189
203
  }
190
204
 
191
205
  /** The comparison of a prediction. Without it, `<=` applies to a measure where lower is better, and `>=` to the other measures. */
@@ -0,0 +1,316 @@
1
+ import { polylineLength } from "../../geom.ts";
2
+ import { follow } from "../../highways/follow.ts";
3
+ import type { HighwayInput, HighwayPlan } from "../../highways/model.ts";
4
+ import { sharedWaysPlan } from "../../highways/shared-ways/index.ts";
5
+ import type { Graph, Result, Route } from "../../model.ts";
6
+ import { byCollection } from "../../routers/trunks.ts";
7
+ import { STRATEGIES } from "../../strategies/builtins.ts";
8
+ import { asRouter, defineStrategy, getStrategy, type Strategy } from "../../strategies/model.ts";
9
+ import { inkSaved } from "../measures/bundles.ts";
10
+ import { quantile } from "../measures/stats.ts";
11
+ import { exactStretch } from "../exact/index.ts";
12
+ import { mulberry32 } from "./hash.ts";
13
+
14
+ /** The rule of the exact model: `collection` lets only the edges of one collection share a link, and `all` lets each edge share a link. */
15
+ export type GapRule = "collection" | "all";
16
+
17
+ /** The rules of the gap closed, in the order of the records. */
18
+ export const GAP_RULES: readonly GapRule[] = ["collection", "all"];
19
+
20
+ /**
21
+ * A view of the gap closed: one rule, or `chosen`. The view `chosen` takes a rule for each run.
22
+ * It takes `all` when a shared segment of the run holds edges of two collections. Else it takes `collection`.
23
+ */
24
+ export type GapView = GapRule | "chosen";
25
+
26
+ /** The views of the gap closed, in the order of the tables. */
27
+ export const GAP_VIEWS: readonly GapView[] = ["chosen", "collection", "all"];
28
+
29
+ /** The stretch caps of the exact optima, from small to large. */
30
+ export const GAP_CAPS: readonly number[] = [1.1, 1.25, 1.5];
31
+
32
+ /** The baseline of each rule: the router `shared` for `collection`, and the plan `shared-ways-all` after `follow` for `all`. */
33
+ export const GAP_BASELINES: Readonly<Record<GapRule, string>> = { collection: "shared", all: "shared-ways-all" };
34
+
35
+ /** The parameters of the paired bootstrap of the gap closed, from `docs/PROMOTION.md`. */
36
+ export const GAP_BOOTSTRAP: { readonly runs: number; readonly level: number; readonly seed: number } = { runs: 10_000, level: 0.95, seed: 1 };
37
+
38
+ /** The optimum of the exact model on one input, for one rule and one stretch cap. The file of the optima of a set holds one for each. */
39
+ export interface ExactOptimum {
40
+ /** The id of the input. */
41
+ readonly input: string;
42
+ /** The hash of the input file. The screen uses the optimum only when the hash matches. */
43
+ readonly hash: string;
44
+ /** The class of the input: its family in the set. */
45
+ readonly class: string;
46
+ readonly nodes: number;
47
+ readonly edges: number;
48
+ readonly rule: GapRule;
49
+ readonly cap: number;
50
+ /** The status of HiGHS: `optimal`, `time-limit` or another status. */
51
+ readonly status: string;
52
+ /** This value is `true` when HiGHS proved the optimum within the time limit. */
53
+ readonly proven: boolean;
54
+ /** The objective of the best solution, the dual bound and the relative MIP gap. Each one is `null` when HiGHS gives none. */
55
+ readonly objective: number | null;
56
+ readonly bound: number | null;
57
+ readonly mipGap: number | null;
58
+ /** The time of the solve, in seconds. */
59
+ readonly seconds: number;
60
+ /** The number of binary variables of the model. */
61
+ readonly binaries: number;
62
+ /** The ink saved (B1) of the routes of the best plan after `follow`. The gap closed uses this value. */
63
+ readonly b1: number | null;
64
+ /** The ink saved of the model itself, without `follow`. */
65
+ readonly modelB1: number | null;
66
+ /** The largest bundle stretch (B3) of the routes of the best plan, against the routes of `corners`. */
67
+ readonly b3max: number | null;
68
+ /** The number of highways of the best plan. */
69
+ readonly highways: number;
70
+ }
71
+
72
+ /** The file of the optima of a set (`exact-optima/1`). Later screens read it and do not solve again. */
73
+ export interface ExactOptimaFile {
74
+ readonly schema: "exact-optima/1";
75
+ readonly set: string;
76
+ /** The time limit of each solve, in seconds. */
77
+ readonly seconds: number;
78
+ /** The commit of the code and the CI run of the solves. */
79
+ readonly commit?: string;
80
+ readonly run?: string;
81
+ readonly optima: readonly ExactOptimum[];
82
+ }
83
+
84
+ /** This function gives the file of the optima of a set, with the rows in the order of the input, the rule and the cap. */
85
+ export function optimaFile(set: string, rows: readonly ExactOptimum[], at: { readonly seconds: number; readonly commit?: string; readonly run?: string }): ExactOptimaFile {
86
+ const order = (r: ExactOptimum): string => `${r.input}\u0000${GAP_RULES.indexOf(r.rule)}\u0000${r.cap.toFixed(3)}`;
87
+ const optima = [...rows].sort((a, b) => (order(a) < order(b) ? -1 : order(a) > order(b) ? 1 : 0));
88
+ return { schema: "exact-optima/1", set, seconds: at.seconds, ...(at.commit ? { commit: at.commit } : {}), ...(at.run ? { run: at.run } : {}), optima };
89
+ }
90
+
91
+ /** This function gives the key of an optimum in a map: the input, the rule and the cap. */
92
+ export function optimumKey(input: string, rule: GapRule, cap: number): string {
93
+ return `${input}|${rule}|${cap.toFixed(3)}`;
94
+ }
95
+
96
+ /** This function gives the smallest cap that is at least the stretch, or `undefined` when the stretch is above the largest cap. */
97
+ export function gapCap(stretch: number, caps: readonly number[] = GAP_CAPS): number | undefined {
98
+ return caps.find((c) => stretch <= c + 1e-9);
99
+ }
100
+
101
+ /**
102
+ * This function gives the gap closed: `(b1 - base) / (optimum - base)`.
103
+ * It gives `undefined` when the denominator is 0 or less, because then the baseline is at the optimum or above it.
104
+ */
105
+ export function gapClosed(b1: number, base: number, optimum: number): number | undefined {
106
+ const d = optimum - base;
107
+ return d > 1e-9 ? (b1 - base) / d : undefined;
108
+ }
109
+
110
+ /**
111
+ * This function gives the largest bundle stretch of routes against base routes: the max of `L(r_e) / L(r*_e)` over the edges, as B3 counts them.
112
+ * An edge without a base route, or with a base route of 1 unit or less, does not count. Without such an edge, the value is 1.
113
+ */
114
+ export function maxStretch(routes: readonly Route[], base: readonly Route[]): number {
115
+ const baseLength = new Map<string, number>();
116
+ for (const r of base) baseLength.set(r.edge, polylineLength(r.points));
117
+ let max = 1;
118
+ for (const r of routes) {
119
+ const b = baseLength.get(r.edge);
120
+ if (b === undefined || b <= 1 || r.points.length < 2) continue;
121
+ max = Math.max(max, polylineLength(r.points) / b);
122
+ }
123
+ return max;
124
+ }
125
+
126
+ /** The highway strategy `shared-ways-all`: the plan of the router `shared` without a rule. It is the baseline of the rule `all`. */
127
+ export const sharedWaysAll: Strategy<"highway", object> = defineStrategy({
128
+ step: "highway",
129
+ name: "shared-ways-all",
130
+ describe: "The plan of the router `shared` without a rule.",
131
+ defaults: {},
132
+ schema: {},
133
+ maxNodes: 10_000,
134
+ run: (input: HighwayInput): HighwayPlan => sharedWaysPlan(input, {}),
135
+ });
136
+
137
+ /** This function gives the B1 of the baseline of each rule on a graph. */
138
+ export function gapBaselines(graph: Graph): Record<GapRule, number> {
139
+ const shared = asRouter(getStrategy(STRATEGIES, "router", "shared")).route(graph).routes;
140
+ const all = follow(graph, sharedWaysAll.run({ graph }, {})).routes;
141
+ return { collection: inkSaved(shared), all: inkSaved(all) };
142
+ }
143
+
144
+ /** A median with its paired bootstrap interval. */
145
+ export interface BootstrapMedian {
146
+ readonly median: number;
147
+ readonly lo: number;
148
+ readonly hi: number;
149
+ }
150
+
151
+ /**
152
+ * This function gives the median of the values and its bootstrap interval: `runs` samples of the values with replacement, from the seed `seed`.
153
+ * The interval is the central `level` share of the medians of the samples. Each value is the paired value of one input, so the bootstrap is paired.
154
+ */
155
+ export function bootstrapMedian(values: readonly number[], o: { readonly runs?: number; readonly level?: number; readonly seed?: number } = {}): BootstrapMedian {
156
+ if (values.length === 0) return { median: Number.NaN, lo: Number.NaN, hi: Number.NaN };
157
+ const runs = o.runs ?? GAP_BOOTSTRAP.runs;
158
+ const level = o.level ?? GAP_BOOTSTRAP.level;
159
+ const r = mulberry32(o.seed ?? GAP_BOOTSTRAP.seed);
160
+ const medians: number[] = [];
161
+ const sample = new Array<number>(values.length);
162
+ for (let i = 0; i < runs; i++) {
163
+ for (let j = 0; j < values.length; j++) sample[j] = values[Math.floor(r() * values.length)]!;
164
+ medians.push(quantile(sample, 0.5));
165
+ }
166
+ const tail = (1 - level) / 2;
167
+ return { median: quantile(values, 0.5), lo: quantile(medians, tail), hi: quantile(medians, 1 - tail) };
168
+ }
169
+
170
+ /** The gap closed of one strategy and one view over the inputs of a screen. */
171
+ export interface GapSummary {
172
+ readonly strategy: string;
173
+ readonly rule: GapView;
174
+ /** The number of runs, and the number of runs with a value. A run has no value above the largest cap, or without a proven optimum. */
175
+ readonly runs: number;
176
+ readonly values: number;
177
+ /** The median over the inputs with a value, and its paired bootstrap interval. */
178
+ readonly median: number;
179
+ readonly lo: number;
180
+ readonly hi: number;
181
+ /** The median of each class, by the family of the input. */
182
+ readonly byClass: Readonly<Record<string, number>>;
183
+ /** The number of values at each cap, by the cap. */
184
+ readonly caps: Readonly<Record<string, number>>;
185
+ /** The number of runs with some ink saved (B1 above 0): the runs where the strategy builds a shared segment. */
186
+ readonly builds: number;
187
+ /** The number of runs where a shared segment holds edges of two collections. */
188
+ readonly mixed: number;
189
+ }
190
+
191
+ /** The names of the measures of the gap closed in a record, for a view. */
192
+ export function gapNames(view: GapView): { readonly gap: string; readonly cap: string } {
193
+ return view === "chosen" ? { gap: "route.bundle.gap-closed", cap: "route.bundle.gap-closed.cap" } : { gap: `route.bundle.gap-closed.${view}`, cap: `route.bundle.gap-closed.cap.${view}` };
194
+ }
195
+
196
+ /** This function gives the family of an input id `<set>/<family>/s<seed>`. */
197
+ const familyOf = (input: string): string => input.split("/")[1] ?? input;
198
+
199
+ /**
200
+ * This function gives the gap closed of each strategy and view over records.
201
+ * It sorts them by the view and then by the median, from high to low.
202
+ * A strategy with a value on fewer than half of its runs comes after the others, because its median shows only a few inputs.
203
+ */
204
+ export function summarizeGap(
205
+ records: readonly { readonly strategy: string; readonly input: string; readonly skipped?: string; readonly measures: Readonly<Record<string, number>> }[],
206
+ ): GapSummary[] {
207
+ const out: GapSummary[] = [];
208
+ const names = [...new Set(records.map((r) => r.strategy))];
209
+ for (const rule of GAP_VIEWS) {
210
+ const key = gapNames(rule);
211
+ const rows: GapSummary[] = [];
212
+ for (const strategy of names) {
213
+ const ran = records.filter((r) => r.strategy === strategy && r.skipped === undefined);
214
+ const withValue = ran.filter((r) => r.measures[key.gap] !== undefined);
215
+ const values = withValue.map((r) => r.measures[key.gap]!);
216
+ const boot = bootstrapMedian(values);
217
+ const classes = [...new Set(withValue.map((r) => familyOf(r.input)))].sort();
218
+ const byClass = Object.fromEntries(classes.map((c) => [c, quantile(withValue.filter((r) => familyOf(r.input) === c).map((r) => r.measures[key.gap]!), 0.5)]));
219
+ const caps: Record<string, number> = {};
220
+ for (const r of withValue) {
221
+ const c = String(r.measures[key.cap]);
222
+ caps[c] = (caps[c] ?? 0) + 1;
223
+ }
224
+ const builds = ran.filter((r) => (r.measures["route.bundle.ink-saved"] ?? 0) > 1e-9).length;
225
+ const mixed = ran.filter((r) => r.measures["route.bundle.gap-closed.mixed"] === 1).length;
226
+ rows.push({ strategy, rule, runs: ran.length, values: values.length, median: boot.median, lo: boot.lo, hi: boot.hi, byClass, caps, builds, mixed });
227
+ }
228
+ const few = (r: GapSummary): number => (2 * r.values < r.runs ? 1 : 0);
229
+ rows.sort((a, b) => few(a) - few(b) || (Number.isNaN(b.median) ? -Infinity : b.median) - (Number.isNaN(a.median) ? -Infinity : a.median) || (a.strategy < b.strategy ? -1 : 1));
230
+ out.push(...rows);
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /** This function writes the gap closed as Markdown: one table for each view, sorted by the median. */
236
+ export function formatGap(rows: readonly GapSummary[]): string {
237
+ const f = (v: number): string => (Number.isNaN(v) ? "-" : v.toFixed(3));
238
+ const lines: string[] = [];
239
+ for (const rule of GAP_VIEWS) {
240
+ const mine = rows.filter((r) => r.rule === rule);
241
+ if (mine.length === 0) continue;
242
+ const classes = [...new Set(mine.flatMap((r) => Object.keys(r.byClass)))].sort();
243
+ lines.push(
244
+ "",
245
+ rule === "chosen"
246
+ ? "### The gap closed with the rule of each run (`all` when a shared segment mixes collections, else `collection`)"
247
+ : `### The gap closed with the rule \`${rule}\` (baseline \`${GAP_BASELINES[rule]}\`)`,
248
+ "",
249
+ "The value of an input is `(B1 - B1 of the baseline) / (the optimum B1 - B1 of the baseline)`, at the smallest cap at or above the largest stretch of the routes.",
250
+ "The stretch is the stretch of the exact model: the length of a route without its plug ends, divided by the shortest way in the network of the model.",
251
+ "The interval is the 95% paired bootstrap interval of the median, with 10000 samples.",
252
+ "A strategy with a value on fewer than half of its runs comes after the others. Above the largest cap, a run has no value.",
253
+ "",
254
+ `| strategy | runs | builds | mixed | values | median | interval | ${classes.join(" | ")} | caps 1.1 / 1.25 / 1.5 |`,
255
+ `|---|---:|---:|---:|---:|---:|---|${classes.map(() => "---:").join("|")}|---|`,
256
+ ...mine.map(
257
+ (r) =>
258
+ `| ${r.strategy} | ${r.runs} | ${r.builds} | ${r.mixed} | ${r.values} | ${f(r.median)} | ${f(r.lo)} to ${f(r.hi)} | ${classes.map((c) => f(r.byClass[c] ?? Number.NaN)).join(" | ")} | ${GAP_CAPS.map((c) => r.caps[String(c)] ?? 0).join(" / ")} |`,
259
+ ),
260
+ );
261
+ }
262
+ return lines.join("\n") + "\n";
263
+ }
264
+
265
+ /** This function gives the count of the shared segments of a result (the runs and the bundles) that hold edges of two collections or more. */
266
+ export function mixedSegments(graph: Graph, result: Result): number {
267
+ const key = new Map(graph.edges.map((e) => [e.id, byCollection(e) ?? `\u0000${e.id}`]));
268
+ const shared = [...(result.runs ?? []).map((r) => r.edges), ...(result.bundles ?? []).map((b) => b.edges)];
269
+ return shared.filter((edges) => new Set(edges.map((e) => key.get(e))).size > 1).length;
270
+ }
271
+
272
+ /** This function tells if a shared segment of a result (a run or a bundle) holds edges of two collections. */
273
+ export function mixesCollections(graph: Graph, result: Result): boolean {
274
+ return mixedSegments(graph, result) > 0;
275
+ }
276
+
277
+ /**
278
+ * This function gives the gap measures of the result of one run on an input with optima, by the keys of `MEASURES`.
279
+ * `B3.max` is the largest stretch against the routes of `corners`. `gap.stretch` is the largest stretch as the exact model counts it (`exactStretch`).
280
+ * The cap is the smallest cap at or above `gap.stretch`, so both sides of the gap have the same stretch limit.
281
+ *
282
+ * `gap.<rule>` is the gap closed at the cap `gap.cap.<rule>`. `gap` is the gap closed of the rule of the run.
283
+ * That rule is `all` when `gap.mixed` is 1, else `collection`.
284
+ * A rule has no value above the largest cap or without a proven optimum at the cap.
285
+ * It also has no value when the hash of the optimum is not the hash of the input.
286
+ */
287
+ export function gapValues(
288
+ input: { readonly id: string; readonly hash: string },
289
+ graph: Graph,
290
+ result: Result,
291
+ corners: readonly Route[],
292
+ baselines: Readonly<Record<GapRule, number>>,
293
+ optima: ReadonlyMap<string, ExactOptimum>,
294
+ ): Record<string, number> {
295
+ const routes = result.routes;
296
+ const stretch = exactStretch(graph, routes);
297
+ const mixed = mixesCollections(graph, result);
298
+ const out: Record<string, number> = { "B3.max": maxStretch(routes, corners), "gap.stretch": stretch, "gap.mixed": mixed ? 1 : 0 };
299
+ const cap = gapCap(stretch);
300
+ if (cap === undefined) return out;
301
+ const b1 = inkSaved(routes);
302
+ for (const rule of GAP_RULES) {
303
+ const o = optima.get(optimumKey(input.id, rule, cap));
304
+ if (!o || !o.proven || o.b1 === null || o.hash !== input.hash) continue;
305
+ const g = gapClosed(b1, baselines[rule], o.b1);
306
+ if (g === undefined) continue;
307
+ out[`gap.${rule}`] = g;
308
+ out[`gap.cap.${rule}`] = cap;
309
+ }
310
+ const chosen: GapRule = mixed ? "all" : "collection";
311
+ if (out[`gap.${chosen}`] !== undefined) {
312
+ out["gap"] = out[`gap.${chosen}`]!;
313
+ out["gap.cap"] = cap;
314
+ }
315
+ return out;
316
+ }