Tell a search that gave up from a route that is not there

The route search stops after MAX_VISITED stars and returned null, which everything downstream read
as "the catalogue holds no chain". On the real catalogue that was wrong for real questions: Sol to
HD 120147 (136 pc) at 5 pc is 50 jumps, and the panel said there was no route. The budget also sat
under what the shipped catalogue needs, so it is now 40 000 rather than 20 000: both that route and
a star at 170 pc are found, and Sol to HD 2626 at 6 pc, which used to be refused after 4.7 s, plots
56 jumps in about 2 s.

A search now reports whether it gave up. The range search no longer counts a give-up as proof that
nothing routes below it — that is what reported ranges up to 29% too wide — and it stops after two
of them, since those are the probes that cost the most and settle the least: for HD 2626 at 3 pc it
offers 5.92 pc in about 4 s, against 6.13 pc in 4.7 s. At the panel's widest range the refused route
and the range search are the same question, so it is asked once. Where nothing can be said, the
panel says "Too many stars to search at this range." rather than claiming there is no route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-18 12:49:18 +02:00
co-authored by Claude Opus 5
parent b4017fcafc
commit 862fb65ea4
10 changed files with 278 additions and 81 deletions
+81 -22
View File
@@ -14,7 +14,7 @@ function index(points: StarPoint[]): StarNeighbourhood {
describe('routeBetween', () => {
it('walks the chain a hop at a time when that is all the range allows', () => {
const route = routeBetween(chain(5), 0, 4, 1.5);
const { route } = routeBetween(chain(5), 0, 4, 1.5);
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
expect(route?.totalPc).toBeCloseTo(4);
@@ -24,7 +24,7 @@ describe('routeBetween', () => {
it('goes straight there when the range reaches, however many stars lie between', () => {
// The direct crossing is never longer than a chain through anything — Euclid says so — so a
// range that covers it makes it the answer, and the stars in between are just scenery.
const route = routeBetween(chain(5), 0, 4, 5);
const { route } = routeBetween(chain(5), 0, 4, 5);
expect(route?.stars).toEqual([0, 4]);
expect(route?.totalPc).toBeCloseTo(4);
@@ -33,7 +33,7 @@ describe('routeBetween', () => {
it('picks the shorter of two ways round when neither is a straight line', () => {
// 0 to 3 is 10 pc, out of a 6 pc range. Two ways round, both inside it: through 1, barely
// off the line, or through 2, well off it. Shorter is what "the way there" means.
const route = routeBetween(
const { route } = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 5, y: 0.5, z: 0 },
@@ -56,19 +56,19 @@ describe('routeBetween', () => {
{ id: 2, x: 20, y: 0, z: 0 }
]);
expect(routeBetween(split, 0, 2, 5)).toBeNull();
expect(routeBetween(split, 0, 2, 5)).toEqual({ route: null, gaveUp: false });
});
it('answers nothing for a star that is not there, or for going nowhere', () => {
const line = chain(3);
expect(routeBetween(line, 0, 0, 2)).toBeNull();
expect(routeBetween(line, 0, 99, 2)).toBeNull();
expect(routeBetween(line, 0, 2, 0)).toBeNull();
expect(routeBetween(line, 0, 0, 2).route).toBeNull();
expect(routeBetween(line, 0, 99, 2).route).toBeNull();
expect(routeBetween(line, 0, 2, 0).route).toBeNull();
});
it('reports the longest hop, which is what the range has to cover', () => {
const route = routeBetween(
const { route } = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
@@ -82,11 +82,31 @@ describe('routeBetween', () => {
expect(route?.longestHopPc).toBeCloseTo(4);
});
it('says it gave up rather than that there is no chain, once it has spent its budget', () => {
// Nothing reaches the island, but the crowd around the departure is larger than the budget, so
// the search stops without having looked everywhere the range reaches. Read as "no chain", that
// is a confident wrong answer — and the range search downstream would build on it.
const search = routeBetween(knotAndChain(), 0, ISLAND, 1.5);
expect(search.route).toBeNull();
expect(search.gaveUp).toBe(true);
});
it('reports a genuine dead end as one, having looked everywhere the range reaches', () => {
const split = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 20, y: 0, z: 0 }
]);
expect(routeBetween(split, 0, 2, 5)).toEqual({ route: null, gaveUp: false });
});
it('heads for the destination rather than exhausting a dense knot around the departure', () => {
// The Gaia catalogue in miniature: a crowd around the departure, larger than the search's
// budget, with the only way on a thin chain leading out of it. A search widening evenly from
// the departure spends the budget on the crowd and never reaches the chain's far end.
const route = routeBetween(knotAndChain(), 0, CHAIN_END, 1.5);
const { route } = routeBetween(knotAndChain(), 0, CHAIN_END, 1.5);
expect(route).not.toBeNull();
expect(route!.stars[route!.stars.length - 1]).toBe(CHAIN_END);
@@ -95,24 +115,55 @@ describe('routeBetween', () => {
});
/**
* 21 000 stars scattered through the 30 pc cube around the origin, about ten times the density
* around the real Sun and more than a search's budget, with a chain a parsec a hop running along
* x from the origin out through the crowd and on to 75 pc.
* 45 000 stars scattered through the 30 pc cube around the origin, twenty times the density around
* the real Sun and more than a search's budget, with a chain a parsec a hop running along x from
* the origin out through the crowd and on to 75 pc — and one star at 500 pc that nothing reaches.
*/
const CHAIN_END = 75;
const ISLAND = 999;
function knotAndChain(): StarNeighbourhood {
let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
const knot: StarPoint[] = Array.from({ length: 21000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const chainOut: StarPoint[] = Array.from({ length: CHAIN_END }, (_, i) => ({ id: i + 1, x: i + 1, y: 0, z: 0 }));
return index([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut]);
return index([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: ISLAND, x: 500, y: 0, z: 0 }]);
}
/**
* 45 000 stars in a 10 pc cube — dense enough to stay one connected piece at half a parsec, where
* walking it costs more than a search's budget — with a chain a parsec a hop leaving its edge for
* 30 pc. Its cells are sized for the ranges asked of it, as the real catalogue's are for its own.
*/
const CROWD_CHAIN_END = 25;
const CROWD_ISLAND = 999999;
function crowdedKnot(): StarNeighbourhood {
let seed = 11;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const chainOut: StarPoint[] = Array.from({ length: CROWD_CHAIN_END }, (_, i) => ({ id: i + 1, x: 5 + i + 1, y: 0, z: 0 }));
// One star nothing reaches, for the questions that have no answer.
return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: CROWD_ISLAND, x: 500, y: 0, z: 0 }], 0.5);
}
describe('minimumRangeBetween', () => {
it('works out the range past a dense knot around the departure', () => {
// Past the crowd the chain's hops of a parsec are the only way on, so a parsec is the
// answer, to the half-step the panel rounds up to.
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8)).toBeCloseTo(1, 1);
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8).rangePc).toBeCloseTo(1, 1);
});
it('stops bisecting where a search gave up, and hands back a range that does work', () => {
// Below the chain's own hop of a parsec, the crowd is still one connected piece and larger than
// the budget, so those probes give up. Reading a give-up as "no chain at this range" is what
// used to report ranges up to 29% wider than needed, and went on paying for probes whose
// answers it could not use; the answer now is the narrowest range a chain was found at.
const knot = crowdedKnot();
const needed = minimumRangeBetween(knot, 0, CROWD_CHAIN_END, 2);
expect(needed.least).toBe(false);
expect(needed.rangePc).not.toBeNull();
expect(routeBetween(knot, 0, CROWD_CHAIN_END, needed.rangePc!).route).not.toBeNull();
});
it('names the shortest range that opens a way through', () => {
@@ -123,9 +174,9 @@ describe('minimumRangeBetween', () => {
{ id: 2, x: 5, y: 0, z: 0 }
]);
expect(minimumRangeBetween(stepped, 0, 2, 50)).toBeCloseTo(4);
expect(routeBetween(stepped, 0, 2, 4)).not.toBeNull();
expect(routeBetween(stepped, 0, 2, 3.99)).toBeNull();
expect(minimumRangeBetween(stepped, 0, 2, 50)).toEqual({ rangePc: expect.closeTo(4) as number, least: true });
expect(routeBetween(stepped, 0, 2, 4).route).not.toBeNull();
expect(routeBetween(stepped, 0, 2, 3.99).route).toBeNull();
});
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
@@ -139,8 +190,16 @@ describe('minimumRangeBetween', () => {
const needed = minimumRangeBetween(both, 0, 3, 50);
expect(needed).toBeLessThan(10);
expect(routeBetween(both, 0, 3, needed!)).not.toBeNull();
expect(needed.rangePc).toBeLessThan(10);
expect(routeBetween(both, 0, 3, needed.rangePc!).route).not.toBeNull();
});
it('claims nothing about a ceiling its own search gave up on', () => {
// Nothing reaches the island at any range here, but the crowd spends the budget first, so the
// widest search proves nothing — and neither does the null it hands back.
const needed = minimumRangeBetween(crowdedKnot(), 0, CROWD_ISLAND, 0.5);
expect(needed).toEqual({ rangePc: null, least: false });
});
it('finds nothing when even the ceiling does not reach', () => {
@@ -149,7 +208,7 @@ describe('minimumRangeBetween', () => {
{ id: 1, x: 100, y: 0, z: 0 }
]);
expect(minimumRangeBetween(split, 0, 1, 50)).toBeNull();
expect(minimumRangeBetween(split, 0, 1, 50)).toEqual({ rangePc: null, least: true });
});
});
@@ -296,7 +355,7 @@ describe('jumpLinkSegments', () => {
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
const route = routeBetween(cloud, 0, 119, range);
const { route } = routeBetween(cloud, 0, 119, range);
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
expect(route).not.toBeNull();
expect(route!.stars.length).toBeGreaterThan(2);
+75 -27
View File
@@ -16,6 +16,21 @@
import { StarNeighbourhood } from './star-neighbourhood';
/** What a search found, and whether it looked everywhere the range reaches before answering. */
export interface RouteSearch {
readonly route: Route | null;
/** True when the search spent its budget: "no route" then means "gave up", not "there is none". */
readonly gaveUp: boolean;
}
/** A range that opens a route, and whether anything shorter was actually ruled out. */
export interface RangeSearch {
/** A range a chain was found at, or `null` where none was found up to the ceiling. */
readonly rangePc: number | null;
/** True when every shorter range was searched to exhaustion, so this is the least that works. */
readonly least: boolean;
}
/** A chain of stars from one to another, each hop within the range that was asked for. */
export interface Route {
/** Star ids, departure first and destination last. One hop is two ids. */
@@ -30,11 +45,17 @@ export interface Route {
}
/**
* A cap on how much of the catalogue one search may walk. A search that hits it has already
* visited more stars than any real chain passes through: the longest measured, Sol to HD 2626 at
* 236 pc in jumps of 8 pc, settles about 7 000.
* A cap on how much of the catalogue one search may walk, so a hopeless question cannot run for
* ever. It is a budget, not a verdict: a search that spends it has proved nothing, and says so
* through {@link RouteSearch.gaveUp}.
*
* Sized against the catalogue actually shipped rather than against the longest route. At 20 000 a
* search from the Sun to HD 120147 (136 pc) in jumps of 5 pc gave up, though the chain it wanted,
* 50 jumps, is there to be found; a star at 170 pc needed 58. Both are found at this budget. The
* cost is paid by questions with no answer, which walk the whole of it: from the Sun to the
* farthest star at 8 pc, 0.7 s at 20 000 against 2.1 s here, in the worker.
*/
const MAX_VISITED = 20000;
const MAX_VISITED = 40000;
/**
* How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
@@ -43,6 +64,17 @@ const MAX_VISITED = 20000;
*/
const RANGE_RESOLUTION_PC = 0.05;
/**
* How many of `minimumRangeBetween`'s probes may give up before it answers with what it has.
*
* A probe that finds a route is quick — it heads straight for the destination — while one that
* gives up walks the whole search budget, about two seconds on the real catalogue. Those are also
* the probes that buy the least: they cannot rule anything out. Two of them is the difference
* between an answer of 7.96 pc in half a second and 5.76 pc in seventeen, for a star at 236 pc; it
* lands on 5.97 pc in five.
*/
const MAX_RANGE_GIVE_UPS = 2;
/** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */
class Frontier {
private readonly ids: number[] = [];
@@ -113,8 +145,8 @@ function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): n
}
/**
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or
* `null` where the catalogue holds no such chain.
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or no
* chain where the catalogue holds none within the search's budget.
*
* Shortest by total distance travelled rather than by number of hops: two chains of the same
* length are not equally good, and the one that covers less ground is the one a reader means by
@@ -126,12 +158,15 @@ function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): n
* than widening evenly in every direction. Widening evenly is what the Gaia catalogue broke. From
* the Sun it spent its whole budget on the 20 000 stars nearest, all inside about 40 pc, and so
* found no route to anything farther at any range; Mirfak, 155 pc out, is 27 jumps at 8 pc.
*
* "No route" and "no chain" are not the same answer: a search that spends {@link MAX_VISITED}
* reports that it gave up, so nothing downstream reads it as proof that no chain exists.
*/
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): RouteSearch {
const origin = index.point(fromId);
const destination = index.point(toId);
if (fromId === toId || rangePc <= 0 || !origin || !destination) {
return null;
return { route: null, gaveUp: false };
}
const straightLineOn = (x: number, y: number, z: number) => Math.hypot(destination.x - x, destination.y - y, destination.z - z);
@@ -156,13 +191,13 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
if (starId === toId) {
const stars = rebuild(cameFrom, fromId, toId);
if (stars.length === 0) {
return null;
return { route: null, gaveUp: false };
}
let longestHopPc = 0;
for (let i = 1; i < stars.length; i++) {
longestHopPc = Math.max(longestHopPc, hopTo.get(stars[i])!);
}
return { stars, totalPc: costHere, longestHopPc };
return { route: { stars, totalPc: costHere, longestHopPc }, gaveUp: false };
}
index.forEachWithin(starId, rangePc, (neighbour, distancePc) => {
@@ -179,38 +214,51 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
});
}
return null;
// An empty frontier means the range reaches nothing further; a spent budget means only that the
// search stopped looking.
return { route: null, gaveUp: settled.size >= MAX_VISITED };
}
/**
* The shortest range at which any chain at all exists between two stars, to within
* `RANGE_RESOLUTION_PC`, or `null` if none does within `ceilingPc`.
* A range at which a chain exists between two stars — the shortest, to within
* `RANGE_RESOLUTION_PC`, where every shorter range could be ruled out — or `null` where no chain
* was found up to `ceilingPc`.
*
* This is what turns "no route" from a dead end into an answer: the range control can be told
* what it would have to be raised to. The exact figure is the minimax path, the chain whose
* longest hop is as short as possible. It used to be searched for directly, widening from the
* departure in order of the worst hop needed, which from the Sun meant exhausting the whole dense
* core before anything farther could be reached: it gave up with nothing after up to a minute.
* Whether a chain exists can only become truer as the range grows, so the range is bisected
* instead, each step one directed `routeBetween`.
* This is what turns "no route" from a dead end into an answer: the range control can be told what
* it would have to be raised to. The figure aimed at is the minimax path, the chain whose longest
* hop is as short as possible. It used to be searched for directly, widening from the departure in
* order of the worst hop needed, which from the Sun meant exhausting the whole dense core before
* anything farther could be reached: it gave up with nothing after up to a minute. Whether a chain
* exists can only become truer as the range grows, so the range is bisected instead, each step one
* directed `routeBetween`.
*
* Each step has to answer "is there a chain at this range", and a search that gives up answers
* nothing. It is still worth carrying on from — the ranges above it are the ones left to try — but
* the result is no longer the least range, only a range that works, and `least` says which. The
* number of steps is bounded for the same reason: each one that gives up walks the whole budget,
* and 11 s of them for a star at 236 pc bought two decimal places nobody reads.
*/
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch {
const widest = routeBetween(index, fromId, toId, ceilingPc);
if (!widest) {
return null;
if (!widest.route) {
return { rangePc: null, least: !widest.gaveUp };
}
let unreachable = 0;
let reachable = widest.longestHopPc;
while (reachable - unreachable > RANGE_RESOLUTION_PC) {
let reachable = widest.route.longestHopPc;
let giveUps = 0;
while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < MAX_RANGE_GIVE_UPS) {
const range = (unreachable + reachable) / 2;
const route = routeBetween(index, fromId, toId, range);
const { route, gaveUp } = routeBetween(index, fromId, toId, range);
if (route) {
reachable = route.longestHopPc;
} else {
// A search that gave up is worth going on from — the ranges above it are the ones left to
// try — but it is not evidence that nothing routes here, so the answer stops being the least.
unreachable = range;
giveUps += gaveUp ? 1 : 0;
}
}
return reachable;
return { rangePc: reachable, least: giveUps === 0 && reachable - unreachable <= RANGE_RESOLUTION_PC };
}
/** How much of a graph to keep: the links nearest a point, up to a total length. */
+40 -3
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
import { answerRouting, indexCatalogue } from './routing';
import { StarNeighbourhood } from './star-neighbourhood';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** Stars a parsec apart along x, then a gap of 5 pc to one more. */
const POINTS = [...Array.from({ length: 5 }, (_, i) => ({ id: 10 + i, x: i, y: 0, z: 0 })), { id: 99, x: 9, y: 0, z: 0 }];
@@ -25,6 +25,16 @@ describe('indexCatalogue', () => {
});
});
/** The same index, counting the neighbour queries a search makes through it. */
class CountingNeighbourhood extends StarNeighbourhood {
queries = 0;
override forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
this.queries++;
super.forEachWithin(id, radiusPc, visit);
}
}
describe('answerRouting', () => {
const index = indexCatalogue(catalogue());
const direct = new StarNeighbourhood(POINTS);
@@ -32,16 +42,43 @@ describe('answerRouting', () => {
it('answers a route the range allows, with nothing to raise it to', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5), neededRangePc: null });
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5).route, neededRangePc: null, gaveUp: false });
});
it('answers a route the range does not allow with the range that would', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8) });
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8).rangePc, gaveUp: false });
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
});
it('passes on that the search gave up, rather than reporting no route', () => {
// A crowd larger than a search's budget around the departure, and a destination nothing reaches:
// the answer is "it gave up", and the scene has to be able to tell that from "there is none".
let seed = 5;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
const crowd: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const knot = new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: 99, x: 500, y: 0, z: 0 }], 0.5);
const answer = answerRouting(knot, { kind: 'route', requestId: 12, fromId: 0, toId: 99, rangePc: 0.5, ceilingPc: 0.5 });
expect(answer).toMatchObject({ route: null, neededRangePc: null, gaveUp: true });
});
it('asks the ceiling its question once, rather than searching it again to answer it', () => {
// At the panel's widest range the refused route and the range search are the same question, run
// with the same arguments over the same index: the second pays the whole budget for the answer
// the first already gave.
const counting = new CountingNeighbourhood(POINTS);
const oneSearch = new CountingNeighbourhood(POINTS);
routeBetween(oneSearch, 10, 99, 3);
const answer = answerRouting(counting, { kind: 'route', requestId: 11, fromId: 10, toId: 99, rangePc: 3, ceilingPc: 3 });
expect(answer).toMatchObject({ route: null, neededRangePc: null });
expect(counting.queries).toBe(oneSearch.queries);
});
it('offers nothing to raise to when even the ceiling does not reach', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
+16 -10
View File
@@ -26,7 +26,11 @@ export type RoutingRequest =
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget };
export type RoutingResponse =
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null }
/**
* `gaveUp` is true when the searches spent their budget rather than looking everywhere: there
* being no route and no range to offer is then what was found, not what exists.
*/
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null; readonly gaveUp: boolean }
| { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array }
/** The question threw in the worker. Sent back so the request settles instead of waiting for good. */
| { readonly kind: 'failed'; readonly requestId: number; readonly message: string };
@@ -37,8 +41,9 @@ export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighb
}
/**
* Answers one request. A route that cannot be made comes back with the range that would make
* one, searched no wider than `ceilingPc`, so a refusal is always also an offer.
* Answers one request. A route that cannot be made comes back with the range that would make one,
* searched no wider than `ceilingPc`, so a refusal is usually also an offer — unless the searches
* gave up, which is reported rather than passed off as "there is no route".
*/
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
if (request.kind === 'links') {
@@ -47,11 +52,12 @@ export function answerRouting(index: StarNeighbourhood, request: RoutingRequest)
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc);
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) };
}
const route = routeBetween(index, request.fromId, request.toId, request.rangePc);
return {
kind: 'route',
requestId: request.requestId,
route,
neededRangePc: route ? null : minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc)
};
const { route, gaveUp } = routeBetween(index, request.fromId, request.toId, request.rangePc);
// At the ceiling the question has just been asked: the range search would repeat it, identically
// and at the same cost, before bisecting below it.
if (route || request.rangePc >= request.ceilingPc) {
return { kind: 'route', requestId: request.requestId, route, neededRangePc: null, gaveUp: !route && gaveUp };
}
const needed = minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc);
return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp: gaveUp || !needed.least };
}