Merge pull request #30 from avalon-vanguard/fix/route-search-budget

Tell a search that gave up from a route that is not there
This commit is contained in:
2026-09-18 19:00:03 +02:00
10 changed files with 344 additions and 83 deletions
@@ -423,7 +423,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
await advanceFrames(engine, 0.3); await advanceFrames(engine, 0.3);
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null }); component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null, gaveUp: false, least: true });
await advanceFrames(engine, 0.3); await advanceFrames(engine, 0.3);
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third. // As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
@@ -473,7 +473,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]); expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]);
// A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph. // A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph.
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null }); component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
await advanceFrames(engine, 0.3); await advanceFrames(engine, 0.3);
await settle(); await settle();
expect(links).toHaveBeenCalledTimes(2); expect(links).toHaveBeenCalledTimes(2);
@@ -595,12 +595,12 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
}); });
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => { it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => {
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null }; type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean; least: boolean };
const answers: Array<(answer: Answer) => void> = []; const answers: Array<(answer: Answer) => void> = [];
const component = fixture.componentInstance as unknown as { const component = fixture.componentInstance as unknown as {
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void }; routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
routePending(): boolean; routePending(): boolean;
routeResult(): { stars: { id: number }[] } | null; routeResult(): { stars: { id: number }[]; gaveUp: boolean } | null;
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void; onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
}; };
component.routing = { component.routing = {
@@ -613,13 +613,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 }); component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
expect(component.routePending()).toBe(true); expect(component.routePending()).toBe(true);
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null }); answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false, least: true });
await flushAsync(); await flushAsync();
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null }); answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false, least: true });
await flushAsync(); await flushAsync();
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]); expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
expect(component.routePending()).toBe(false); expect(component.routePending()).toBe(false);
// "It gave up" travels to the panel, which says something else for it than for "there is none".
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 0.5 });
answers[2]({ route: null, neededRangePc: null, gaveUp: true, least: false });
await flushAsync();
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
}); });
it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => { it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
@@ -1528,7 +1528,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const request = ++this.routeRequest; const request = ++this.routeRequest;
this.routePending.set(true); this.routePending.set(true);
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then( void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
({ route, neededRangePc }) => { ({ route, neededRangePc, gaveUp, least }) => {
if (request !== this.routeRequest) { if (request !== this.routeRequest) {
return; return;
} }
@@ -1536,7 +1536,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.routeResult.set({ this.routeResult.set({
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [], stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
totalPc: route?.totalPc ?? 0, totalPc: route?.totalPc ?? 0,
neededRangePc neededRangePc,
gaveUp,
least
}); });
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id)); this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
}, },
@@ -73,7 +73,7 @@ describe('RoutingClient without a worker', () => {
it('answers a route from the index it was given', async () => { it('answers a route from the index it was given', async () => {
const client = new RoutingClient(STARS, POSITIONS, index); const client = new RoutingClient(STARS, POSITIONS, index);
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null }); await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
client.dispose(); client.dispose();
}); });
@@ -143,8 +143,8 @@ describe('RoutingClient with a worker', () => {
await expect(first).resolves.toHaveLength(6); await expect(first).resolves.toHaveLength(6);
const routeRequest = worker.requests[1]; const routeRequest = worker.requests[1];
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4 }); worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4 }); await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true });
await flush(); await flush();
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]); expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
@@ -161,14 +161,14 @@ describe('RoutingClient with a worker', () => {
const widerRange = client.route(100, 104, 2.5, 8); const widerRange = client.route(100, 104, 2.5, 8);
expect(worker.requests).toHaveLength(1); expect(worker.requests).toHaveLength(1);
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4 }); worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
expect(await again).toEqual(await once); expect(await again).toEqual(await once);
await flush(); await flush();
// The same two stars at another range is another question. // The same two stars at another range is another question.
expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]); expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]);
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null }); worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false, least: true });
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null }); await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false, least: true });
client.dispose(); client.dispose();
}); });
@@ -218,6 +218,16 @@ describe('RoutingClient with a worker', () => {
client.dispose(); client.dispose();
}); });
it('hands on that the search gave up, along with the answer it did give', async () => {
const { client, worker } = clientWithFake();
const answer = client.route(100, 105, 1.5, 8);
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: null, gaveUp: true, least: false });
await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true, least: false });
client.dispose();
});
it('rejects a request the worker failed on, and goes on to the next', async () => { it('rejects a request the worker failed on, and goes on to the next', async () => {
const { client, worker } = clientWithFake(); const { client, worker } = clientWithFake();
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error); const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
@@ -240,7 +250,7 @@ describe('RoutingClient with a worker', () => {
worker.fail(); worker.fail();
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null }); await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]); expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]);
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null }); await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
expect(worker.terminated).toBe(true); expect(worker.terminated).toBe(true);
@@ -6,6 +6,10 @@ import { StarRecord } from '../../shared/models/star.model';
export interface RouteAnswer { export interface RouteAnswer {
readonly route: Route | null; readonly route: Route | null;
readonly neededRangePc: number | null; readonly neededRangePc: number | null;
/** True when the search at the range asked for gave up rather than ruling a route out. */
readonly gaveUp: boolean;
/** True when the search for a range that would work looked everywhere up to the ceiling. */
readonly least: boolean;
} }
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */ /** A request dropped before it was sent, because a newer one of the same kind replaced it. */
@@ -104,7 +108,9 @@ export class RoutingClient {
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> { route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> {
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) => return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
response.kind === 'route' ? { route: response.route, neededRangePc: response.neededRangePc } : { route: null, neededRangePc: null } response.kind === 'route'
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp, least: response.least }
: { route: null, neededRangePc: null, gaveUp: false, least: false }
); );
} }
@@ -253,6 +253,33 @@ describe('HudDockComponent', () => {
expect(tab('Readout').getAttribute('aria-selected')).toBe('true'); expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
}); });
it('says the search gave up rather than that there is no route, when that is what happened', () => {
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('defaultTab', 'routes');
const summary = () => host().querySelector('[data-testid="route-summary"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: true, least: false });
fixture.detectChanges();
expect(summary()).toBe('Too many stars to search at this range.');
// Having looked everywhere the range reaches is a different answer, and one that can be stated.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
fixture.detectChanges();
expect(summary()).toContain('No chain of jumps up to');
// A range a chain was found at is worth offering — but the search that gave up at the range
// asked for still gave up, and saying "no route" beside the offer is saying it did not.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true, least: false });
fixture.detectChanges();
expect(summary()).toBe('Too many stars to search at this range. 6.40 pc would reach.');
// The other way round: the range asked for was searched to exhaustion and the wider search was
// the one that gave up. There is no route at this range, and nothing further can be claimed.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: false });
fixture.detectChanges();
expect(summary()).toBe('No route at this range.');
});
it('keeps what the Routes panel was set to across a trip to another tab', () => { it('keeps what the Routes panel was set to across a trip to another tab', () => {
setReadout(); setReadout();
fixture.componentRef.setInput('routing', true); fixture.componentRef.setInput('routing', true);
+11 -3
View File
@@ -20,8 +20,12 @@ export interface RouteResult {
/** The chain, departure first. Empty when there is no route at the range asked for. */ /** The chain, departure first. Empty when there is no route at the range asked for. */
readonly stars: readonly { id: number; name: string }[]; readonly stars: readonly { id: number; name: string }[];
readonly totalPc: number; readonly totalPc: number;
/** The shortest range that would open a route, where none was found at the one asked for. */ /** A range that would open a route, where none was found at the one asked for. */
readonly neededRangePc: number | null; readonly neededRangePc: number | null;
/** True when the search gave up rather than showing there is no route at this range. */
readonly gaveUp: boolean;
/** True when the search for a range that would work looked everywhere up to the widest offered. */
readonly least: boolean;
} }
export interface RouteRequest { export interface RouteRequest {
@@ -113,7 +117,11 @@ type Field = 'from' | 'to';
</p> </p>
} @else { } @else {
<p data-testid="route-summary" class="text-sm text-muted"> <p data-testid="route-summary" class="text-sm text-muted">
No route at this range. @if (plotted.gaveUp) {
Too many stars to search at this range.
} @else {
No route at this range.
}
@if (plotted.neededRangePc !== null) { @if (plotted.neededRangePc !== null) {
<button <button
type="button" type="button"
@@ -122,7 +130,7 @@ type Field = 'from' | 'to';
> >
{{ format(plotted.neededRangePc) }} would reach. {{ format(plotted.neededRangePc) }} would reach.
</button> </button>
} @else { } @else if (plotted.least) {
No chain of jumps up to {{ format(maxRangePc) }} reaches it. No chain of jumps up to {{ format(maxRangePc) }} reaches it.
} }
</p> </p>
+101 -23
View File
@@ -14,7 +14,7 @@ function index(points: StarPoint[]): StarNeighbourhood {
describe('routeBetween', () => { describe('routeBetween', () => {
it('walks the chain a hop at a time when that is all the range allows', () => { 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?.stars).toEqual([0, 1, 2, 3, 4]);
expect(route?.totalPc).toBeCloseTo(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', () => { 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 // 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. // 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?.stars).toEqual([0, 4]);
expect(route?.totalPc).toBeCloseTo(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', () => { 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 // 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. // off the line, or through 2, well off it. Shorter is what "the way there" means.
const route = routeBetween( const { route } = routeBetween(
index([ index([
{ id: 0, x: 0, y: 0, z: 0 }, { id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 5, y: 0.5, 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 } { 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', () => { it('answers nothing for a star that is not there, or for going nowhere', () => {
const line = chain(3); const line = chain(3);
expect(routeBetween(line, 0, 0, 2)).toBeNull(); expect(routeBetween(line, 0, 0, 2).route).toBeNull();
expect(routeBetween(line, 0, 99, 2)).toBeNull(); expect(routeBetween(line, 0, 99, 2).route).toBeNull();
expect(routeBetween(line, 0, 2, 0)).toBeNull(); expect(routeBetween(line, 0, 2, 0).route).toBeNull();
}); });
it('reports the longest hop, which is what the range has to cover', () => { it('reports the longest hop, which is what the range has to cover', () => {
const route = routeBetween( const { route } = routeBetween(
index([ index([
{ id: 0, x: 0, y: 0, z: 0 }, { id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 }, { id: 1, x: 1, y: 0, z: 0 },
@@ -82,11 +82,32 @@ describe('routeBetween', () => {
expect(route?.longestHopPc).toBeCloseTo(4); expect(route?.longestHopPc).toBeCloseTo(4);
}); });
it('says it gave up rather than that there is no chain, once it has spent its budget', { timeout: 30_000 }, () => {
// 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.
// Cells sized for the range asked of them, as the real catalogue's are: a search that settles
// 40 000 stars scans every cell it touches 40 000 times.
const search = routeBetween(knotAndChain(1.5), 0, ISLAND, 1.5);
expect(search.route).toBeNull();
expect(search.gaveUp).toBe(true);
});
it('reports a dead end proved with the last star of the budget as a dead end, not a give-up', () => {
// Exactly the budget's worth of stars reach each other, and the destination is not among them.
// The search does look everywhere the range reaches, so "no chain" is what it found — but the
// set is full at the end of it, and a budget read off the settled count says it gave up.
const search = routeBetween(budgetExactly(), 0, BUDGET_ISLAND, 1.5);
expect(search).toEqual({ route: null, gaveUp: false });
});
it('heads for the destination rather than exhausting a dense knot around the departure', () => { 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 // 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 // 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. // 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).not.toBeNull();
expect(route!.stars[route!.stars.length - 1]).toBe(CHAIN_END); expect(route!.stars[route!.stars.length - 1]).toBe(CHAIN_END);
@@ -95,24 +116,73 @@ describe('routeBetween', () => {
}); });
/** /**
* 21 000 stars scattered through the 30 pc cube around the origin, about ten times the density * 45 000 stars scattered through the 30 pc cube around the origin, twenty times the density around
* around the real Sun and more than a search's budget, with a chain a parsec a hop running along * the real Sun and more than a search's budget, with a chain a parsec a hop running along x from
* x from the origin out through the crowd and on to 75 pc. * 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 CHAIN_END = 75;
function knotAndChain(): StarNeighbourhood { const ISLAND = 999;
function knotAndChain(cellSizePc?: number): StarNeighbourhood {
let seed = 7; let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15; 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 })); 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 new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: ISLAND, x: 500, y: 0, z: 0 }], cellSizePc);
}
/**
* Exactly a search's budget of stars that reach one another — 40 000 a parsec apart along x, which
* a 1.5 pc range walks end to end — and one 500 pc off that line, which nothing reaches. The dead
* end is real and the search proves it, with the last star it is allowed.
*
* A line rather than a crowd because the count has to be exact: a random cloud dense enough to
* connect leaves clumps the departure never reaches, and 39 662 of 40 000 settled is a budget that
* was never spent.
*/
const BUDGET_ISLAND = 99_999;
function budgetExactly(): StarNeighbourhood {
const line: StarPoint[] = Array.from({ length: 40_000 }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
return new StarNeighbourhood([...line, { id: BUDGET_ISLAND, x: 0, y: 500, z: 0 }], 1.5);
}
/**
* 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.25);
} }
describe('minimumRangeBetween', () => { describe('minimumRangeBetween', () => {
it('works out the range past a dense knot around the departure', () => { 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 // 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. // 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', { timeout: 30_000 }, () => {
// 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, 1.2);
expect(needed.least).toBe(false);
expect(needed.rangePc).not.toBeNull();
expect(routeBetween(knot, 0, CROWD_CHAIN_END, needed.rangePc!).route).not.toBeNull();
// Narrower than the ceiling's own route, too: stopping before the bisection has found a range
// of its own hands back the ceiling, which is the control's maximum — the question, not an answer.
expect(needed.rangePc!).toBeLessThan(routeBetween(knot, 0, CROWD_CHAIN_END, 1.2).route!.longestHopPc);
}); });
it('names the shortest range that opens a way through', () => { it('names the shortest range that opens a way through', () => {
@@ -123,9 +193,9 @@ describe('minimumRangeBetween', () => {
{ id: 2, x: 5, y: 0, z: 0 } { id: 2, x: 5, y: 0, z: 0 }
]); ]);
expect(minimumRangeBetween(stepped, 0, 2, 50)).toBeCloseTo(4); expect(minimumRangeBetween(stepped, 0, 2, 50)).toEqual({ rangePc: expect.closeTo(4) as number, least: true });
expect(routeBetween(stepped, 0, 2, 4)).not.toBeNull(); expect(routeBetween(stepped, 0, 2, 4).route).not.toBeNull();
expect(routeBetween(stepped, 0, 2, 3.99)).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', () => { it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
@@ -139,8 +209,16 @@ describe('minimumRangeBetween', () => {
const needed = minimumRangeBetween(both, 0, 3, 50); const needed = minimumRangeBetween(both, 0, 3, 50);
expect(needed).toBeLessThan(10); expect(needed.rangePc).toBeLessThan(10);
expect(routeBetween(both, 0, 3, needed!)).not.toBeNull(); 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', () => { it('finds nothing when even the ceiling does not reach', () => {
@@ -149,7 +227,7 @@ describe('minimumRangeBetween', () => {
{ id: 1, x: 100, y: 0, z: 0 } { 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 +374,7 @@ describe('jumpLinkSegments', () => {
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points)); 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. // Asserted, not guarded: a skipped body would let the two disagree unnoticed.
expect(route).not.toBeNull(); expect(route).not.toBeNull();
expect(route!.stars.length).toBeGreaterThan(2); expect(route!.stars.length).toBeGreaterThan(2);
+98 -28
View File
@@ -16,6 +16,21 @@
import { StarNeighbourhood } from './star-neighbourhood'; 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. */ /** A chain of stars from one to another, each hop within the range that was asked for. */
export interface Route { export interface Route {
/** Star ids, departure first and destination last. One hop is two ids. */ /** 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 * A cap on how much of the catalogue one search may walk, so a hopeless question cannot run for
* visited more stars than any real chain passes through: the longest measured, Sol to HD 2626 at * ever. It is a budget, not a verdict: a search that spends it has proved nothing, and says so
* 236 pc in jumps of 8 pc, settles about 7 000. * 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 * How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
@@ -43,6 +64,23 @@ const MAX_VISITED = 20000;
*/ */
const RANGE_RESOLUTION_PC = 0.05; const RANGE_RESOLUTION_PC = 0.05;
/**
* How many of `minimumRangeBetween`'s probes may give up, once it has a range of its own, before it
* answers with what it has — and how many before it has one.
*
* 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.
*
* Until a probe succeeds there is nothing to answer with but the ceiling route's own longest hop,
* which is the control's maximum, so the bound is looser there — but a bound, since the search is
* one the panel waits on: five probes, ten seconds, rather than the resolution's own eight.
*/
const MAX_RANGE_GIVE_UPS = 2;
const MAX_UNEARNED_GIVE_UPS = 5;
/** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */ /** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */
class Frontier { class Frontier {
private readonly ids: number[] = []; private readonly ids: number[] = [];
@@ -113,8 +151,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 * The shortest chain from one star to another in which no single hop exceeds `rangePc`, or no
* `null` where the catalogue holds no such chain. * 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 * 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 * length are not equally good, and the one that covers less ground is the one a reader means by
@@ -126,12 +164,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 * 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 * 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. * 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 origin = index.point(fromId);
const destination = index.point(toId); const destination = index.point(toId);
if (fromId === toId || rangePc <= 0 || !origin || !destination) { 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); const straightLineOn = (x: number, y: number, z: number) => Math.hypot(destination.x - x, destination.y - y, destination.z - z);
@@ -145,24 +186,31 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
const frontier = new Frontier(); const frontier = new Frontier();
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z)); frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
while (frontier.size > 0 && settled.size < MAX_VISITED) { let gaveUp = false;
while (frontier.size > 0) {
const starId = frontier.pop(); const starId = frontier.pop();
if (settled.has(starId)) { if (settled.has(starId)) {
continue; continue;
} }
// Counted against the budget only once the frontier has been drained of stale duplicates, so
// the flag below records why the search stopped rather than how full the set happened to be.
if (settled.size >= MAX_VISITED) {
gaveUp = true;
break;
}
settled.add(starId); settled.add(starId);
const costHere = travelled.get(starId)!; const costHere = travelled.get(starId)!;
if (starId === toId) { if (starId === toId) {
const stars = rebuild(cameFrom, fromId, toId); const stars = rebuild(cameFrom, fromId, toId);
if (stars.length === 0) { if (stars.length === 0) {
return null; return { route: null, gaveUp: false };
} }
let longestHopPc = 0; let longestHopPc = 0;
for (let i = 1; i < stars.length; i++) { for (let i = 1; i < stars.length; i++) {
longestHopPc = Math.max(longestHopPc, hopTo.get(stars[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) => { index.forEachWithin(starId, rangePc, (neighbour, distancePc) => {
@@ -179,38 +227,60 @@ 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 };
} }
/** /**
* The shortest range at which any chain at all exists between two stars, to within * A range at which a chain exists between two stars — the shortest, to within
* `RANGE_RESOLUTION_PC`, or `null` if none does within `ceilingPc`. * `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 * This is what turns "no route" from a dead end into an answer: the range control can be told what
* what it would have to be raised to. The exact figure is the minimax path, the chain whose * it would have to be raised to. The figure aimed at is the minimax path, the chain whose longest
* longest hop is as short as possible. It used to be searched for directly, widening from the * hop is as short as possible. It used to be searched for directly, widening from the departure in
* departure in order of the worst hop needed, which from the Sun meant exhausting the whole dense * order of the worst hop needed, which from the Sun meant exhausting the whole dense core before
* core before anything farther could be reached: it gave up with nothing after up to a minute. * anything farther could be reached: it gave up with nothing after up to a minute. Whether a chain
* Whether a chain exists can only become truer as the range grows, so the range is bisected * exists can only become truer as the range grows, so the range is bisected instead, each step one
* instead, each step one directed `routeBetween`. * 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 that may give up is bounded for the same reason: each one walks the whole budget,
* and 11 s of them for a star at 236 pc bought two decimal places nobody reads. Bounded more
* loosely before the bisection has found a range of its own, since until then the only range it
* could offer is the ceiling's, which is the control's maximum, for crossings that work well below
* it. See {@link MAX_RANGE_GIVE_UPS}.
*/ */
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); const widest = routeBetween(index, fromId, toId, ceilingPc);
if (!widest) { if (!widest.route) {
return null; return { rangePc: null, least: !widest.gaveUp };
} }
const ceilingHopPc = widest.route.longestHopPc;
let unreachable = 0; let unreachable = 0;
let reachable = widest.longestHopPc; let reachable = ceilingHopPc;
while (reachable - unreachable > RANGE_RESOLUTION_PC) { let giveUps = 0;
// While `reachable` is still the ceiling route's own longest hop the bisection has nothing of its
// own to answer with, and that figure sends the control to its maximum for a crossing that works
// well below — 8.00 pc for a star that routes at 6. So it is allowed more probes there, not
// unlimited ones: the panel is waiting on this.
while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < (reachable === ceilingHopPc ? MAX_UNEARNED_GIVE_UPS : MAX_RANGE_GIVE_UPS)) {
const range = (unreachable + reachable) / 2; const range = (unreachable + reachable) / 2;
const route = routeBetween(index, fromId, toId, range); const { route, gaveUp } = routeBetween(index, fromId, toId, range);
if (route) { if (route) {
reachable = route.longestHopPc; reachable = route.longestHopPc;
} else { } 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; unreachable = range;
giveUps += gaveUp ? 1 : 0;
} }
} }
return reachable; // Without a give-up the loop can only have ended by closing on the resolution, so that is the least.
return { rangePc: reachable, least: giveUps === 0 };
} }
/** How much of a graph to keep: the links nearest a point, up to a total length. */ /** 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 { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
import { answerRouting, indexCatalogue } from './routing'; 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. */ /** 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 }]; 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', () => { describe('answerRouting', () => {
const index = indexCatalogue(catalogue()); const index = indexCatalogue(catalogue());
const direct = new StarNeighbourhood(POINTS); const direct = new StarNeighbourhood(POINTS);
@@ -32,16 +42,43 @@ describe('answerRouting', () => {
it('answers a route the range allows, with nothing to raise it to', () => { 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 }); 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, least: true });
}); });
it('answers a route the range does not allow with the range that would', () => { 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 }); 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, least: true });
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1); 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', () => { 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 }); const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
+27 -10
View File
@@ -26,7 +26,21 @@ export type RoutingRequest =
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget }; | { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget };
export type RoutingResponse = export type RoutingResponse =
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null } /**
* Two searches, and two things they can fail to prove, kept apart because they are printed as
* different sentences. `gaveUp` is about the range that was asked for: true when that search
* spent its budget rather than looking everywhere the range reaches. `least` is about the search
* for a range that would work: true when it looked everywhere up to the ceiling, so `null` there
* means no chain exists rather than none was found.
*/
| {
readonly kind: 'route';
readonly requestId: number;
readonly route: Route | null;
readonly neededRangePc: number | null;
readonly gaveUp: boolean;
readonly least: boolean;
}
| { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array } | { 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. */ /** 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 }; | { readonly kind: 'failed'; readonly requestId: number; readonly message: string };
@@ -37,8 +51,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 * Answers one request. A route that cannot be made comes back with the range that would make one,
* one, searched no wider than `ceilingPc`, so a refusal is always also an offer. * 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 { export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
if (request.kind === 'links') { if (request.kind === 'links') {
@@ -47,11 +62,13 @@ export function answerRouting(index: StarNeighbourhood, request: RoutingRequest)
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc); 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) }; return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) };
} }
const route = routeBetween(index, request.fromId, request.toId, request.rangePc); const { route, gaveUp } = routeBetween(index, request.fromId, request.toId, request.rangePc);
return { // At the ceiling the question has just been asked: the range search would repeat it, identically
kind: 'route', // and at the same cost, before bisecting below it.
requestId: request.requestId, if (route || request.rangePc >= request.ceilingPc) {
route, // Asked at the ceiling, the one search answers both questions.
neededRangePc: route ? null : minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc) return { kind: 'route', requestId: request.requestId, route, neededRangePc: null, gaveUp: !route && gaveUp, least: !gaveUp };
}; }
const needed = minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc);
return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp, least: needed.least };
} }