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 }; }