From 862fb65ea4e348d8161df8bb3c5c142db58e85a9 Mon Sep 17 00:00:00 2001
From: Senrokai
Date: Fri, 18 Sep 2026 12:49:18 +0200
Subject: [PATCH 1/5] Tell a search that gave up from a route that is not there
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
---
.../galaxy-system-scene.component.spec.ts | 18 ++-
.../galaxy-system-scene.component.ts | 5 +-
.../galaxy-system/routing-client.spec.ts | 24 ++--
.../features/galaxy-system/routing-client.ts | 6 +-
.../features/hud/hud-dock.component.spec.ts | 20 ++++
.../features/hud/routes-panel.component.ts | 12 +-
src/app/shared/astro/jump-links.spec.ts | 103 ++++++++++++++----
src/app/shared/astro/jump-links.ts | 102 ++++++++++++-----
src/app/shared/astro/routing.spec.ts | 43 +++++++-
src/app/shared/astro/routing.ts | 26 +++--
10 files changed, 278 insertions(+), 81 deletions(-)
diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts
index e669b9b..dfd9092 100644
--- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts
+++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts
@@ -423,7 +423,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
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 });
await advanceFrames(engine, 0.3);
// 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]);
// 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 });
await advanceFrames(engine, 0.3);
await settle();
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 () => {
- 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 };
const answers: Array<(answer: Answer) => void> = [];
const component = fixture.componentInstance as unknown as {
routing: { route(): Promise; links(): Promise; dispose(): void };
routePending(): boolean;
- routeResult(): { stars: { id: number }[] } | null;
+ routeResult(): { stars: { id: number }[]; gaveUp: boolean } | null;
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
};
component.routing = {
@@ -613,13 +613,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
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 });
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 });
await flushAsync();
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
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 });
+ 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 () => {
diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts
index 971993d..7a693b9 100644
--- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts
+++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts
@@ -1528,7 +1528,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const request = ++this.routeRequest;
this.routePending.set(true);
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
- ({ route, neededRangePc }) => {
+ ({ route, neededRangePc, gaveUp }) => {
if (request !== this.routeRequest) {
return;
}
@@ -1536,7 +1536,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.routeResult.set({
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
totalPc: route?.totalPc ?? 0,
- neededRangePc
+ neededRangePc,
+ gaveUp
});
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
},
diff --git a/src/app/features/galaxy-system/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts
index 2d05f31..314cbe4 100644
--- a/src/app/features/galaxy-system/routing-client.spec.ts
+++ b/src/app/features/galaxy-system/routing-client.spec.ts
@@ -73,7 +73,7 @@ describe('RoutingClient without a worker', () => {
it('answers a route from the index it was given', async () => {
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 });
client.dispose();
});
@@ -143,8 +143,8 @@ describe('RoutingClient with a worker', () => {
await expect(first).resolves.toHaveLength(6);
const routeRequest = worker.requests[1];
- worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4 });
- await expect(route).resolves.toEqual({ route: null, neededRangePc: 4 });
+ worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false });
+ await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false });
await flush();
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);
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 });
expect(await again).toEqual(await once);
await flush();
// The same two stars at another range is another question.
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 });
- await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null });
+ worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false });
+ await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false });
client.dispose();
});
@@ -218,6 +218,16 @@ describe('RoutingClient with a worker', () => {
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 });
+
+ await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true });
+ client.dispose();
+ });
+
it('rejects a request the worker failed on, and goes on to the next', async () => {
const { client, worker } = clientWithFake();
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
@@ -240,7 +250,7 @@ describe('RoutingClient with a worker', () => {
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 });
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 });
expect(worker.terminated).toBe(true);
diff --git a/src/app/features/galaxy-system/routing-client.ts b/src/app/features/galaxy-system/routing-client.ts
index 3677913..58bf63f 100644
--- a/src/app/features/galaxy-system/routing-client.ts
+++ b/src/app/features/galaxy-system/routing-client.ts
@@ -6,6 +6,8 @@ import { StarRecord } from '../../shared/models/star.model';
export interface RouteAnswer {
readonly route: Route | null;
readonly neededRangePc: number | null;
+ /** True when the search gave up rather than ruling a route out; see `routeBetween`. */
+ readonly gaveUp: boolean;
}
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
@@ -104,7 +106,9 @@ export class RoutingClient {
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise {
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 }
+ : { route: null, neededRangePc: null, gaveUp: false }
);
}
diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts
index 9018ecf..bee9dd5 100644
--- a/src/app/features/hud/hud-dock.component.spec.ts
+++ b/src/app/features/hud/hud-dock.component.spec.ts
@@ -253,6 +253,26 @@ describe('HudDockComponent', () => {
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 });
+ 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 });
+ fixture.detectChanges();
+ expect(summary()).toContain('No chain of jumps up to');
+
+ // A range a chain was found at is worth offering whether or not anything shorter was ruled out.
+ fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true });
+ fixture.detectChanges();
+ expect(summary()).toBe('No route at this range. 6.40 pc would reach.');
+ });
+
it('keeps what the Routes panel was set to across a trip to another tab', () => {
setReadout();
fixture.componentRef.setInput('routing', true);
diff --git a/src/app/features/hud/routes-panel.component.ts b/src/app/features/hud/routes-panel.component.ts
index 395b2b9..b7304fb 100644
--- a/src/app/features/hud/routes-panel.component.ts
+++ b/src/app/features/hud/routes-panel.component.ts
@@ -20,8 +20,10 @@ export interface RouteResult {
/** The chain, departure first. Empty when there is no route at the range asked for. */
readonly stars: readonly { id: number; name: string }[];
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;
+ /** True when the search gave up rather than showing there is no route at this range. */
+ readonly gaveUp: boolean;
}
export interface RouteRequest {
@@ -113,7 +115,11 @@ type Field = 'from' | 'to';
} @else {
- No route at this range.
+ @if (plotted.gaveUp && plotted.neededRangePc === null) {
+ Too many stars to search at this range.
+ } @else {
+ No route at this range.
+ }
@if (plotted.neededRangePc !== null) {
- } @else {
+ } @else if (!plotted.gaveUp) {
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
}
diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts
index b070a93..54d251a 100644
--- a/src/app/shared/astro/jump-links.spec.ts
+++ b/src/app/shared/astro/jump-links.spec.ts
@@ -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);
diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts
index fca2f03..3f36c51 100644
--- a/src/app/shared/astro/jump-links.ts
+++ b/src/app/shared/astro/jump-links.ts
@@ -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, 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, 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. */
diff --git a/src/app/shared/astro/routing.spec.ts b/src/app/shared/astro/routing.spec.ts
index 8509a45..0cf2004 100644
--- a/src/app/shared/astro/routing.spec.ts
+++ b/src/app/shared/astro/routing.spec.ts
@@ -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 });
diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts
index f917d7c..6c10473 100644
--- a/src/app/shared/astro/routing.ts
+++ b/src/app/shared/astro/routing.ts
@@ -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 };
}
From 7971ec400772738bff4f1646ec5922627a67e419 Mon Sep 17 00:00:00 2001
From: Senrokai
Date: Fri, 18 Sep 2026 12:56:51 +0200
Subject: [PATCH 2/5] Simplify: without a give-up the bisection can only have
closed on the resolution
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
---
src/app/shared/astro/jump-links.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts
index 3f36c51..a6ea876 100644
--- a/src/app/shared/astro/jump-links.ts
+++ b/src/app/shared/astro/jump-links.ts
@@ -258,7 +258,8 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to
giveUps += gaveUp ? 1 : 0;
}
}
- return { rangePc: reachable, least: giveUps === 0 && reachable - unreachable <= RANGE_RESOLUTION_PC };
+ // 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. */
From 7ab92e61a1cb2ab3ee2b593ea364265ca8c3df1f Mon Sep 17 00:00:00 2001
From: Senrokai
Date: Fri, 18 Sep 2026 13:50:47 +0200
Subject: [PATCH 3/5] Give the budget tests room and cells to run in
Both make a search spend its whole 40 000-star budget, twice over in the bisection, and the
CI runner timed out at the default five seconds. The crowds are now indexed in cells sized for the
ranges asked of them, as the real catalogue is, and the two tests carry their own 30 s timeout.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
---
src/app/shared/astro/jump-links.spec.ts | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts
index 54d251a..ef693ef 100644
--- a/src/app/shared/astro/jump-links.spec.ts
+++ b/src/app/shared/astro/jump-links.spec.ts
@@ -82,11 +82,13 @@ 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', () => {
+ 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.
- const search = routeBetween(knotAndChain(), 0, ISLAND, 1.5);
+ // 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);
@@ -121,12 +123,12 @@ describe('routeBetween', () => {
*/
const CHAIN_END = 75;
const ISLAND = 999;
-function knotAndChain(): StarNeighbourhood {
+function knotAndChain(cellSizePc?: number): StarNeighbourhood {
let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
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, { id: ISLAND, x: 500, y: 0, z: 0 }]);
+ return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: ISLAND, x: 500, y: 0, z: 0 }], cellSizePc);
}
/**
@@ -142,7 +144,7 @@ function crowdedKnot(): StarNeighbourhood {
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);
+ 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', () => {
@@ -152,14 +154,14 @@ describe('minimumRangeBetween', () => {
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', () => {
+ 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, 2);
+ const needed = minimumRangeBetween(knot, 0, CROWD_CHAIN_END, 1.2);
expect(needed.least).toBe(false);
expect(needed.rangePc).not.toBeNull();
From 1a53f26474aef61847f8a106828a16bf5dcfc0af Mon Sep 17 00:00:00 2001
From: Senrokai
Date: Fri, 18 Sep 2026 14:46:47 +0200
Subject: [PATCH 4/5] Answer the review: say which search gave up, and let the
bisection earn its offer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The panel printed "No route at this range." beside the range it was offering —
which is the sentence this branch exists to stop it printing. It was gated on
there being no offer, and a search that gives up usually has one: Sol to
HD 120147 at 4.5 pc spends the budget, offers 4.83 pc, and says there is no
route where a 71-jump route exists. The wording now follows the search at the
range that was asked for, and nothing else.
That needs the two give-ups kept apart, so `least` travels beside `gaveUp` to
the panel: one says the asked range was not searched out, the other that the
search for a range that would work was. HIP 69445 at 3 pc — asked-range search
exhaustive in 44 ms, ceiling probe out of budget — used to read "Too many stars
to search at this range." and now reads "No route at this range.", with nothing
claimed after it.
Two more from the same review. The budget flag was read off the settled count,
so a search that proved a dead end with the last star it was allowed reported a
give-up; it now records why the loop stopped. And the bisection's cap could fire
before a single probe had narrowed anything, leaving the ceiling route's own
longest hop as the answer: star 1000115173 at 3 pc was told to go to 8.00 pc,
the control's maximum, for a crossing that works at 6. It now offers 6.93.
Measured in the app, all three: "Too many stars to search at this range.
4.90 pc would reach.", "No route at this range." alone, and 7.00 pc in place of
8.00. The duplicated dead-end test now asks the question it was named for —
exactly the budget's worth of stars reaching each other and none of them the
destination — and each fix kills its own mutant.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
---
.../galaxy-system-scene.component.spec.ts | 12 ++++----
.../galaxy-system-scene.component.ts | 5 ++--
.../galaxy-system/routing-client.spec.ts | 18 ++++++------
.../features/galaxy-system/routing-client.ts | 8 +++--
.../features/hud/hud-dock.component.spec.ts | 17 +++++++----
.../features/hud/routes-panel.component.ts | 6 ++--
src/app/shared/astro/jump-links.spec.ts | 29 ++++++++++++++-----
src/app/shared/astro/jump-links.ts | 25 ++++++++++++----
src/app/shared/astro/routing.spec.ts | 4 +--
src/app/shared/astro/routing.ts | 21 ++++++++++----
10 files changed, 98 insertions(+), 47 deletions(-)
diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts
index dfd9092..9529727 100644
--- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts
+++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts
@@ -423,7 +423,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
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, gaveUp: false });
+ 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);
// 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]);
// 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, gaveUp: false });
+ component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
await advanceFrames(engine, 0.3);
await settle();
expect(links).toHaveBeenCalledTimes(2);
@@ -595,7 +595,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
});
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; gaveUp: boolean };
+ type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean; least: boolean };
const answers: Array<(answer: Answer) => void> = [];
const component = fixture.componentInstance as unknown as {
routing: { route(): Promise; links(): Promise; dispose(): void };
@@ -613,9 +613,9 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
expect(component.routePending()).toBe(true);
- answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false });
+ answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false, least: true });
await flushAsync();
- answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false });
+ answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false, least: true });
await flushAsync();
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
@@ -623,7 +623,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
// "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 });
+ answers[2]({ route: null, neededRangePc: null, gaveUp: true, least: false });
await flushAsync();
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
});
diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts
index 7a693b9..9778c27 100644
--- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts
+++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts
@@ -1528,7 +1528,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const request = ++this.routeRequest;
this.routePending.set(true);
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
- ({ route, neededRangePc, gaveUp }) => {
+ ({ route, neededRangePc, gaveUp, least }) => {
if (request !== this.routeRequest) {
return;
}
@@ -1537,7 +1537,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
totalPc: route?.totalPc ?? 0,
neededRangePc,
- gaveUp
+ gaveUp,
+ least
});
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
},
diff --git a/src/app/features/galaxy-system/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts
index 314cbe4..feca7df 100644
--- a/src/app/features/galaxy-system/routing-client.spec.ts
+++ b/src/app/features/galaxy-system/routing-client.spec.ts
@@ -73,7 +73,7 @@ describe('RoutingClient without a worker', () => {
it('answers a route from the index it was given', async () => {
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).route, neededRangePc: null, gaveUp: false });
+ 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();
});
@@ -143,8 +143,8 @@ describe('RoutingClient with a worker', () => {
await expect(first).resolves.toHaveLength(6);
const routeRequest = worker.requests[1];
- worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false });
- await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false });
+ worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
+ await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true });
await flush();
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);
expect(worker.requests).toHaveLength(1);
- worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false });
+ worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
expect(await again).toEqual(await once);
await flush();
// The same two stars at another range is another question.
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, gaveUp: false });
- await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false });
+ 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, gaveUp: false, least: true });
client.dispose();
});
@@ -222,9 +222,9 @@ describe('RoutingClient with a worker', () => {
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 });
+ 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 });
+ await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true, least: false });
client.dispose();
});
@@ -250,7 +250,7 @@ describe('RoutingClient with a worker', () => {
worker.fail();
- await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false });
+ 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]);
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
expect(worker.terminated).toBe(true);
diff --git a/src/app/features/galaxy-system/routing-client.ts b/src/app/features/galaxy-system/routing-client.ts
index 58bf63f..d213093 100644
--- a/src/app/features/galaxy-system/routing-client.ts
+++ b/src/app/features/galaxy-system/routing-client.ts
@@ -6,8 +6,10 @@ import { StarRecord } from '../../shared/models/star.model';
export interface RouteAnswer {
readonly route: Route | null;
readonly neededRangePc: number | null;
- /** True when the search gave up rather than ruling a route out; see `routeBetween`. */
+ /** 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. */
@@ -107,8 +109,8 @@ export class RoutingClient {
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise {
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
response.kind === 'route'
- ? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp }
- : { route: null, neededRangePc: null, gaveUp: false }
+ ? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp, least: response.least }
+ : { route: null, neededRangePc: null, gaveUp: false, least: false }
);
}
diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts
index bee9dd5..26c5412 100644
--- a/src/app/features/hud/hud-dock.component.spec.ts
+++ b/src/app/features/hud/hud-dock.component.spec.ts
@@ -258,19 +258,26 @@ describe('HudDockComponent', () => {
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 });
+ 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 });
+ 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 whether or not anything shorter was ruled out.
- fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true });
+ // 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('No route at this range. 6.40 pc would reach.');
+ 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', () => {
diff --git a/src/app/features/hud/routes-panel.component.ts b/src/app/features/hud/routes-panel.component.ts
index b7304fb..584aadb 100644
--- a/src/app/features/hud/routes-panel.component.ts
+++ b/src/app/features/hud/routes-panel.component.ts
@@ -24,6 +24,8 @@ export interface RouteResult {
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 {
@@ -115,7 +117,7 @@ type Field = 'from' | 'to';
} @else {
- @if (plotted.gaveUp && plotted.neededRangePc === null) {
+ @if (plotted.gaveUp) {
Too many stars to search at this range.
} @else {
No route at this range.
@@ -128,7 +130,7 @@ type Field = 'from' | 'to';
>
{{ format(plotted.neededRangePc) }} would reach.
- } @else if (!plotted.gaveUp) {
+ } @else if (plotted.least) {
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
}
diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts
index ef693ef..fc3f95d 100644
--- a/src/app/shared/astro/jump-links.spec.ts
+++ b/src/app/shared/astro/jump-links.spec.ts
@@ -94,14 +94,13 @@ describe('routeBetween', () => {
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 }
- ]);
+ it('reports a dead end proved with the last star of the budget as a dead end, not a give-up', { timeout: 30_000 }, () => {
+ // 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(routeBetween(split, 0, 2, 5)).toEqual({ route: null, gaveUp: false });
+ expect(search).toEqual({ route: null, gaveUp: false });
});
it('heads for the destination rather than exhausting a dense knot around the departure', () => {
@@ -131,6 +130,19 @@ function knotAndChain(cellSizePc?: number): StarNeighbourhood {
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 — 39 999 through the 30 pc cube around
+ * the origin, plus the departure — and one at 500 pc that nothing reaches. The dead end is real and
+ * the search proves it, with the last star it is allowed.
+ */
+const BUDGET_ISLAND = 99_999;
+function budgetExactly(): StarNeighbourhood {
+ let seed = 13;
+ const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
+ const crowd: StarPoint[] = Array.from({ length: 39_999 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
+ return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: BUDGET_ISLAND, x: 500, y: 0, 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
@@ -166,6 +178,9 @@ describe('minimumRangeBetween', () => {
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', () => {
diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts
index a6ea876..c5fbce1 100644
--- a/src/app/shared/astro/jump-links.ts
+++ b/src/app/shared/astro/jump-links.ts
@@ -180,11 +180,18 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
const frontier = new Frontier();
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();
if (settled.has(starId)) {
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);
const costHere = travelled.get(starId)!;
@@ -216,7 +223,7 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
// 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 };
+ return { route: null, gaveUp };
}
/**
@@ -235,18 +242,24 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
* 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.
+ * 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, but not
+ * before the bisection has found a range of its own: 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.
*/
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch {
const widest = routeBetween(index, fromId, toId, ceilingPc);
if (!widest.route) {
return { rangePc: null, least: !widest.gaveUp };
}
+ const ceilingHopPc = widest.route.longestHopPc;
let unreachable = 0;
- let reachable = widest.route.longestHopPc;
+ let reachable = ceilingHopPc;
let giveUps = 0;
- while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < MAX_RANGE_GIVE_UPS) {
+ // The cap cannot fire while `reachable` is still the ceiling route's own longest hop: that is
+ // the question, not an answer the bisection earned, and offering it sends the control to its
+ // maximum for a crossing that works well below — 8.00 pc for a star that routes at 6.
+ while (reachable - unreachable > RANGE_RESOLUTION_PC && (giveUps < MAX_RANGE_GIVE_UPS || reachable === ceilingHopPc)) {
const range = (unreachable + reachable) / 2;
const { route, gaveUp } = routeBetween(index, fromId, toId, range);
if (route) {
diff --git a/src/app/shared/astro/routing.spec.ts b/src/app/shared/astro/routing.spec.ts
index 0cf2004..2b975ea 100644
--- a/src/app/shared/astro/routing.spec.ts
+++ b/src/app/shared/astro/routing.spec.ts
@@ -42,13 +42,13 @@ 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).route, neededRangePc: null, gaveUp: false });
+ 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', () => {
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).rangePc, gaveUp: false });
+ 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);
});
diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts
index 6c10473..521f89d 100644
--- a/src/app/shared/astro/routing.ts
+++ b/src/app/shared/astro/routing.ts
@@ -27,10 +27,20 @@ export type RoutingRequest =
export type RoutingResponse =
/**
- * `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.
+ * 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 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 }
/** 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 };
@@ -56,8 +66,9 @@ export function answerRouting(index: StarNeighbourhood, request: RoutingRequest)
// 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 };
+ // Asked at the ceiling, the one search answers both questions.
+ 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: gaveUp || !needed.least };
+ return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp, least: needed.least };
}
From 337602f6064029853595af610ab2ae30fdf5a057 Mon Sep 17 00:00:00 2001
From: Senrokai
Date: Fri, 18 Sep 2026 15:51:38 +0200
Subject: [PATCH 5/5] Make the budget test spend the budget, and bound the
probes that earn nothing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The fixture built for "exactly MAX_VISITED stars reachable" was 338 short: its
random cloud leaves clumps the departure never reaches (the LCG gives 12 212
distinct positions for 39 999 stars), so the search settled 39 662 and the
pre-fix code answered `gaveUp: false` too. The test could not fail on the code
it was written to pin — and the mutant that seemed to prove otherwise was
failing to compile, not failing the test. It is now a line of 40 000 a parsec
apart with the island off the line: settled 40 000 exactly, 115 ms, and the
pre-fix code does report a give-up. Both mutants now compile and are caught.
The give-up cap also has to hold while the bisection has earned nothing: the
exception added for that case had no bound at all, so a search could spend the
resolution's own eight full-budget probes — about 17 s of "Plotting…" — where
two used to cost 4 s. Bounded at five. On the repo's crowded-knot fixture:
1.9 s for the unearned ceiling figure with the old cap, 7.2 s for a range the
bisection earned, and five probes is where that lands.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
---
src/app/shared/astro/jump-links.spec.ts | 18 ++++++++++--------
src/app/shared/astro/jump-links.ts | 24 ++++++++++++++++--------
2 files changed, 26 insertions(+), 16 deletions(-)
diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts
index fc3f95d..420da4a 100644
--- a/src/app/shared/astro/jump-links.spec.ts
+++ b/src/app/shared/astro/jump-links.spec.ts
@@ -94,7 +94,7 @@ describe('routeBetween', () => {
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', { timeout: 30_000 }, () => {
+ 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.
@@ -131,16 +131,18 @@ function knotAndChain(cellSizePc?: number): StarNeighbourhood {
}
/**
- * Exactly a search's budget of stars that reach one another — 39 999 through the 30 pc cube around
- * the origin, plus the departure — and one at 500 pc that nothing reaches. The dead end is real and
- * the search proves it, with the last star it is allowed.
+ * 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 {
- let seed = 13;
- const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
- const crowd: StarPoint[] = Array.from({ length: 39_999 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
- return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: BUDGET_ISLAND, x: 500, y: 0, z: 0 }], 1.5);
+ 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);
}
/**
diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts
index c5fbce1..3aa3bbc 100644
--- a/src/app/shared/astro/jump-links.ts
+++ b/src/app/shared/astro/jump-links.ts
@@ -65,15 +65,21 @@ const MAX_VISITED = 40000;
const RANGE_RESOLUTION_PC = 0.05;
/**
- * How many of `minimumRangeBetween`'s probes may give up before it answers with what it has.
+ * 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. */
class Frontier {
@@ -243,9 +249,10 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
* 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, but not
- * before the bisection has found a range of its own: 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.
+ * 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): RangeSearch {
const widest = routeBetween(index, fromId, toId, ceilingPc);
@@ -256,10 +263,11 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to
let unreachable = 0;
let reachable = ceilingHopPc;
let giveUps = 0;
- // The cap cannot fire while `reachable` is still the ceiling route's own longest hop: that is
- // the question, not an answer the bisection earned, and offering it sends the control to its
- // maximum for a crossing that works well below — 8.00 pc for a star that routes at 6.
- while (reachable - unreachable > RANGE_RESOLUTION_PC && (giveUps < MAX_RANGE_GIVE_UPS || reachable === ceilingHopPc)) {
+ // 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 { route, gaveUp } = routeBetween(index, fromId, toId, range);
if (route) {