From 44f6a8d0864b826cbe20e293f289150f7e18d5b3 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 12:12:49 +0200 Subject: [PATCH 01/11] Refuse a Gaia answer that came back short, and read the body inside the retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge gate asks whether Gaia contributed any stars, never how many. The TAP service truncates on its own timeout and still serves a well-formed CSV with a 200, ordered by magnitude — so a half answer is the bright half, which is the half HYG overlaps. Every gate passes: Gaia stars are present, HYG survivors go down rather than up, unmerged twins can only fall. The weekly job would publish a catalogue missing two hundred thousand stars and the runner would cache it for the weeks after. `fetchGaiaStars` now refuses fewer than 95% of the 412 765 rows its query holds, as its sibling query already did, and refuses an answer that fills the row limit. `fetchText` retried the request but not the body: a connection reset part-way through the 57 MB CSV rejected out of the loop, with no wait and no second attempt. The read now happens inside it. Also corrected: the merge gate's account of the HYG survivors (two thirds of them are stars Gaia measures but the main query never downloads, since Gaia puts them past the 250 pc cutoff), and the refresh workflow's comment on what happens when the archive is unreachable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .github/workflows/data-refresh.yml | 8 ++++--- tools/etl/build.ts | 11 +++++++-- tools/etl/lib/http.ts | 10 +++++++-- tools/etl/sources/gaia.ts | 36 +++++++++++++++++++++++++++--- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.github/workflows/data-refresh.yml b/.github/workflows/data-refresh.yml index fa24a64..bf90aa5 100644 --- a/.github/workflows/data-refresh.yml +++ b/.github/workflows/data-refresh.yml @@ -49,9 +49,11 @@ jobs: key: gaia-dr3-${{ hashFiles('tools/etl/sources/gaia.ts') }} # Every other source is fetched live on this fresh runner. A failed fetch fails the run by - # design — no refresh is better than a partial one. That includes Gaia on a cold cache: - # the ETL skips it when unreachable, and the merge gate in build.ts then refuses a - # catalogue it contributed nothing to. + # design — no refresh is better than a partial one. That includes Gaia on a cold cache, by + # two different paths: its Hipparcos cross-match is required, so an unreachable archive + # fails the run from fetchStars itself, while its main query is skipped when unreachable and + # the merge gate in build.ts then refuses a catalogue it contributed nothing to. An archive + # that answers short rather than not at all is caught in fetchGaiaStars. - name: Rebuild the datasets run: npm run etl diff --git a/tools/etl/build.ts b/tools/etl/build.ts index b213455..6f11c09 100644 --- a/tools/etl/build.ts +++ b/tools/etl/build.ts @@ -66,8 +66,15 @@ function validateStars(stars: StarRecord[]): void { * The other failure leaves no close pair at all, because proper motion had already carried the * two entries tens of arcseconds apart — the 2026-08-24 refresh, where HYG sat at epoch 2000.0 * and Gaia at J2016.0. What it does leave is HYG rows that found no counterpart: 36 056 of them - * against the 10 876 Gaia genuinely lacks (bright stars it saturates on, red dwarfs past its - * magnitude cut). + * against the 10 886 today, and no counterpart was possible for most of those. Two thirds of them, + * 6 835, are the stars Gaia measures but the main query never downloads, because Gaia's parallax + * puts them past `ETL_GAIA_DISTANCE_PC` while Hipparcos put them inside `ETL_STAR_DISTANCE_PC`; + * they are every star in the published catalogue beyond 250 pc. The rest are what Gaia genuinely + * lacks: bright stars it saturates on, red dwarfs past its magnitude cut. So the headroom left to + * the ceiling tracks the gap between those two cutoffs as much as Gaia's completeness. + * + * This bounds a merge that went wrong. It cannot bound a Gaia download that came back short: that + * makes *fewer* survivors, not more, and is guarded where it can be seen, in `fetchGaiaStars`. */ const MAX_UNMERGED_TWINS = 100; const MAX_HYG_SURVIVORS = 15_000; diff --git a/tools/etl/lib/http.ts b/tools/etl/lib/http.ts index 9410ec0..a214332 100644 --- a/tools/etl/lib/http.ts +++ b/tools/etl/lib/http.ts @@ -34,9 +34,15 @@ const RETRY_DELAYS_MS = [30_000, 120_000]; async function fetchText(url: string): Promise { for (let attempt = 0; ; attempt++) { - const response = await fetch(url).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error)))); + let response = await fetch(url).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error)))); if (!(response instanceof Error) && response.ok) { - return response.text(); + // Read inside the loop, because the body is where these downloads fail: the Gaia CSV is + // 57 MB, and a connection reset part-way through rejects here, long after the 200. + const body = await response.text().catch((error: unknown) => (error instanceof Error ? error : new Error(String(error)))); + if (typeof body === 'string') { + return body; + } + response = body; } const reason = response instanceof Error ? response.message : `${response.status} ${response.statusText}`; // A 4xx is the request's own fault, and waiting will not change the answer. diff --git a/tools/etl/sources/gaia.ts b/tools/etl/sources/gaia.ts index c70d6a6..6d02dc9 100644 --- a/tools/etl/sources/gaia.ts +++ b/tools/etl/sources/gaia.ts @@ -38,9 +38,29 @@ const CATALOGUE_EPOCH = 2000.0; * past anything this map draws, so the limit here is a payload decision: the catalogue is baked * into a static asset that a browser downloads before the first frame. */ -const DISTANCE_CUTOFF_PC = Number(process.env['ETL_GAIA_DISTANCE_PC'] ?? 250); -const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? 12); -const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? 500000); +const DEFAULT_DISTANCE_CUTOFF_PC = 250; +const DEFAULT_MAGNITUDE_LIMIT = 12; +const DEFAULT_ROW_LIMIT = 500_000; +const DISTANCE_CUTOFF_PC = Number(process.env['ETL_GAIA_DISTANCE_PC'] ?? DEFAULT_DISTANCE_CUTOFF_PC); +const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? DEFAULT_MAGNITUDE_LIMIT); +const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? DEFAULT_ROW_LIMIT); + +/** + * How many rows the scheduled job's own query holds: 412 765, and DR3 is a finished data release, + * so that number only moves when the query does. + * + * Checked because a short answer looks exactly like a complete one. The TAP service truncates on + * its own timeout and still serves a well-formed CSV with a 200, and the rows are ordered by + * magnitude, so what comes back is the bright half — the half HYG overlaps. The merge gate in + * `build.ts` would then see Gaia stars present, fewer HYG survivors and fewer unmerged twins, and + * pass a catalogue missing two hundred thousand stars, which the weekly job would publish and the + * runner would cache for the weeks after it. Same failure, and same guard, as + * {@link MIN_USABLE_HIP_DISTANCES} below. + * + * Only checked for that query: the environment overrides exist to fetch a smaller slice on purpose. + */ +const DEFAULT_QUERY_ROWS = 412_765; +const MIN_ROW_SHARE = 0.95; /** * Relative parallax error above which a star is dropped: a parallax measured to worse than 20% @@ -92,6 +112,16 @@ export async function fetchGaiaStars(): Promise { // response arrived, not what it answered. const csv = await fetchTextCached(url, `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`); const rows = parseCsvObjects(csv); + const jobsQuery = DISTANCE_CUTOFF_PC === DEFAULT_DISTANCE_CUTOFF_PC && MAGNITUDE_LIMIT === DEFAULT_MAGNITUDE_LIMIT && ROW_LIMIT === DEFAULT_ROW_LIMIT; + if (jobsQuery && rows.length < DEFAULT_QUERY_ROWS * MIN_ROW_SHARE) { + throw new Error( + `Gaia returned ${rows.length} rows, not the ~${DEFAULT_QUERY_ROWS} this query holds — the answer was cut short, ` + + 'or was an error page served with a 200; delete tools/etl/.cache/gaia-dr3-*.csv once the archive answers properly' + ); + } + if (rows.length >= ROW_LIMIT) { + throw new Error(`Gaia returned the query's own ${ROW_LIMIT}-row limit, so it is the limit deciding what the map holds; raise ETL_GAIA_ROW_LIMIT.`); + } const stars: StarRecord[] = []; rows.forEach((row, index) => { From 862fb65ea4e348d8161df8bb3c5c142db58e85a9 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 12:49:18 +0200 Subject: [PATCH 02/11] 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 03/11] 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 5dec528cee36b4161e3aee2db413f37db284f99a Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 13:24:16 +0200 Subject: [PATCH 04/11] Size the distance rings by what the frame reaches, and keep their labels off the star names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of #19. The rings are distances from the Sun, but their step was taken from `effectiveDistance`, which under the plan view means the extent of the frame rather than how far the camera is from the Sun. Centred on a star 200 pc out and flipped to 2D, the grid became rings of 2 to 20 pc: not one of them on screen. The step now comes from where the view is centred plus how far the camera is orbiting it, which is the same distance under either projection. The set was also rebuilt while the grid was hidden, and every rebuild disposes the rings and builds every vertex again; it now happens only while the grid is drawn. The ring labels went straight to the overlay: never culled to the frame, and free to land on a star's name. They now have to be on screen and clear of the names already placed, by half the separation two names keep — they are a ladder up one ray a twentieth of the screen apart, and holding them apart from each other would take "Survey edge" off the map. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 89 +++++++++++++++++++ .../galaxy-system-scene.component.ts | 57 ++++++++++-- src/app/shared/format/scale-bar.ts | 7 +- 3 files changed, 141 insertions(+), 12 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..70d5d66 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 @@ -15,6 +15,7 @@ import { HudDisplay } from '../hud/hud-dock.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { JumpLinkRenderer } from './jump-link-renderer'; import { StarFieldRenderer } from './star-field-renderer'; +import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; // jsdom does not implement ResizeObserver; the component only uses it to react to real // layout changes, which never happen in this headless test. @@ -418,6 +419,94 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { }); }); + describe('the local grid of distance rings', () => { + type GridScene = { + controls: { target: THREE.Vector3; update(): void }; + display: { update(change: (display: HudDisplay) => HudDisplay): void }; + localGridRadii: readonly number[]; + }; + + it('sizes the rings by how far the frame reaches from the Sun, under either projection', async () => { + const component = fixture.componentInstance as unknown as GridScene; + const camera = engine.getCamera(); + // Centred on a star 200 pc out, seen from 20 pc away: the rings have to reach it. + component.controls.target.set(200, 0, 0); + camera.position.set(200, 0, 20); + component.controls.update(); + await advanceFrames(engine, 0.3); + const underPerspective = [...component.localGridRadii]; + + component.display.update((display) => ({ ...display, plan: true })); + TestBed.tick(); + await advanceFrames(engine, 0.3); + + expect(underPerspective.at(-1)).toBeGreaterThanOrEqual(200); + // The plan view's wheel moves the frame rather than the camera, so "how far out the camera + // is" means something else there; what the rings have to cover does not. + expect([...component.localGridRadii]).toEqual(underPerspective); + }); + + it('leaves the rings alone while the grid is not drawn', async () => { + const component = fixture.componentInstance as unknown as GridScene; + const camera = engine.getCamera(); + await advanceFrames(engine, 0.3); + component.display.update((display) => ({ ...display, grid: false })); + TestBed.tick(); + await advanceFrames(engine, 0.3); + const hidden = [...component.localGridRadii]; + + // A zoom this size crosses two round steps, and each crossing rebuilds every ring's vertices. + camera.position.setLength(camera.position.length() / 8); + component.controls.update(); + await advanceFrames(engine, 0.3); + + expect([...component.localGridRadii]).toEqual(hidden); + }); + + it('drops a ring label that a star name has taken, or that is off screen, and keeps the ladder otherwise', () => { + const component = fixture.componentInstance as unknown as { + ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[]; + }; + const camera = engine.getCamera(); + camera.updateMatrixWorld(true); + const at = (x: number, y: number) => new THREE.Vector3(x, y, 0.5).unproject(camera); + const near = at(0.1, 0.1); + const nextRungUp = at(0.1, 0.16); + const offScreen = at(1.6, 0.1); + const ladder: LabeledPoint[] = [ + { id: 'ring-50', name: '50 pc', x: near.x, y: near.y, z: near.z }, + { id: 'ring-100', name: '100 pc', x: nextRungUp.x, y: nextRungUp.y, z: nextRungUp.z }, + { id: 'ring-150', name: '150 pc', x: offScreen.x, y: offScreen.y, z: offScreen.z } + ]; + + // A ladder of rings stays whole, though its rungs are closer than two star names would be. + expect(component.ringLabelsInTheClear(ladder, camera, []).map((label) => label.id)).toEqual(['ring-50', 'ring-100']); + // A star's name is worth more than a distance. + const star: LabeledPoint = { id: 7, name: 'Sirius', x: near.x, y: near.y, z: near.z }; + expect(component.ringLabelsInTheClear(ladder, camera, [star]).map((label) => label.id)).toEqual(['ring-100']); + }); + + it('places the ring labels with the star names rather than over them', async () => { + const component = fixture.componentInstance as unknown as GridScene; + const update = vi.spyOn(StarLabelOverlay.prototype, 'update'); + const cleared = vi.spyOn(GalaxySystemSceneComponent.prototype as unknown as { ringLabelsInTheClear: (...args: unknown[]) => LabeledPoint[] }, 'ringLabelsInTheClear'); + const camera = engine.getCamera(); + camera.position.set(0, 4, 10); + component.controls.target.set(0, 0, 0); + component.controls.update(); + await advanceFrames(engine, 0.3); + + const labels = (update.mock.calls.at(-1)?.[0] ?? []) as LabeledPoint[]; + const rings = labels.filter((label) => String(label.id).startsWith('ring-')); + expect(rings.length).toBeGreaterThan(0); + // Handed over as the clearing pass left them, not as the grid produced them. + expect(cleared).toHaveBeenCalled(); + expect(rings).toEqual(cleared.mock.results.at(-1)?.value); + update.mockRestore(); + cleared.mockRestore(); + }); + }); + it('keeps the stars of a plotted route drawn, and the selected star', async () => { const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } }; const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); 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..2250ae1 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -70,6 +70,12 @@ const LABEL_MAX_COUNT = 15; const LABEL_MIN_SEPARATION_NDC = 0.12; /** Beyond this the text of a right-hand label would run off the view: hang it on the left. */ const LABEL_EDGE_NDC = 0.7; +/** + * How far a ring label has to sit from a star's name, in NDC — half what two star names keep + * between them. A ring label is one short line, and the rungs of its ladder are a twentieth of the + * screen apart, so the full separation would have one name clear three rungs. + */ +const RING_LABEL_CLEARANCE_NDC = LABEL_MIN_SEPARATION_NDC / 2; /** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */ const LABEL_REACH_NDC = 0.3; /** @@ -194,10 +200,11 @@ const GALACTIC_FAR_PC = 250000; */ const SURVEY_EDGE_PC = 250; /** - * The local grid's rings are distances from the Sun, at a round step that follows the camera: - * five of them out to about the camera's own distance, so 50 to 250 pc from the opening view and - * 2 to 10 pc from beside the Sun. A fixed set could only serve one end of the zoom: 50 pc rings - * say nothing from inside a 2 pc hop, and nothing marked the stars now drawn past 250 pc. + * How many rings the local grid aims for: the step is rounded down from a fifth of how far the + * frame reaches from the Sun, which makes five to fourteen of them. So 50 to 350 pc from the + * opening view, and 2 to 10 pc from beside the Sun. A fixed set could only serve one end of the + * zoom: 50 pc rings say nothing from inside a 2 pc hop, and nothing marked the stars now drawn + * past 250 pc. */ const LOCAL_GRID_RING_COUNT = 5; const LOCAL_GRID_SPOKES = 12; @@ -912,11 +919,29 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } } + /** + * The ring labels worth drawing: the ones on screen, and clear of the star names already placed. + * + * Not held apart from each other, as the star names are: they are a ladder up one ray, a few + * hundredths of the screen apart, and reading them in order is the point. What they must not do + * is sit on a star's name, which is worth more than a distance — or be handed to the overlay + * from behind or beside the camera, which draws them at the edge of the page rather than not at all. + */ + private ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: SceneCamera, stars: readonly LabeledPoint[]): LabeledPoint[] { + const projected = new THREE.Vector3(); + const onScreen = (label: LabeledPoint): THREE.Vector2 | null => { + projected.set(label.x, label.y, label.z).project(camera); + const outside = projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1; + return outside ? null : new THREE.Vector2(projected.x * this.viewportAspect(), projected.y); + }; + const taken = stars.map(onScreen).filter((point): point is THREE.Vector2 => point !== null); + return candidates.filter((label) => { + const point = onScreen(label); + return point !== null && !taken.some((other) => other.distanceTo(point) < RING_LABEL_CLEARANCE_NDC); + }); + } + private updateLabels(camera: SceneCamera): void { - const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); - if (radii.join() !== this.localGridRadii.join()) { - this.setLocalGridRadii(radii); - } const selectedId = this.navigationStore.selectedStarId(); // Measured from what the camera is looking at, not from where it is. Those differ by the // orbit distance, so a camera-relative rule names the stars closest to the near edge of the @@ -928,6 +953,20 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // Individual star names mean nothing once the whole Galaxy is in frame — at that range the // entire catalogue is inside one pixel — so the labels hand over to the structural ones. const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD; + // The rings are distances from the Sun, so what they have to cover is how far from the Sun the + // frame reaches: where the view is centred, plus how far out the camera is orbiting it. Under + // the plan view the orbit distance is the frame's own extent, since that is what the wheel + // moves there. Read as "how far the camera is from the Sun" instead, panning away from the Sun + // and flipping to the plan view left every ring off the frame. + // Rebuilt only while the grid is drawn: each new set disposes the old rings and builds every + // vertex of the new ones, and the set changes on any zoom that crosses a round step. + if (!isGalactic && this.display().grid) { + const orbitPc = this.engine.currentProjection === 'perspective' ? camera.position.distanceTo(target) : this.effectiveDistance(camera); + const radii = distanceRings(target.length() + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); + if (radii.join() !== this.localGridRadii.join()) { + this.setLocalGridRadii(radii); + } + } // Brightest first, not nearest first. Proximity was the right ranking when the catalogue was // a 50 pc bubble and everything in it was equally worth naming; across 250 pc it labels a // clump of whatever happens to be closest to the middle of the screen and never names the @@ -944,7 +983,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { }; const starLabels: LabeledPoint[] = isGalactic ? [] : this.spreadLabels(candidates(this.stars, this.starsByBrightness), camera, selectedId); const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels; - const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabels(camera); + const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabelsInTheClear(this.ringLabels(camera), camera, starLabels); this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]); } diff --git a/src/app/shared/format/scale-bar.ts b/src/app/shared/format/scale-bar.ts index 7eb8f88..ff10fbd 100644 --- a/src/app/shared/format/scale-bar.ts +++ b/src/app/shared/format/scale-bar.ts @@ -17,9 +17,10 @@ export function roundLengthAtMost(value: number): number | null { /** * Rings at a round step of about `reach / count`, out to `reach` or just past it, plus `callout` - * wherever it falls among them: the grid's own radii are round, and the one radius that means - * something in its own right is marked whether the step lands on it or not. Rounding the step - * down makes for `count` to `2.5 × count` rings, never fewer than it takes to cover `reach`. + * where it falls between the first ring and the last: the grid's own radii are round, and the one + * radius that means something in its own right is marked whether the step lands on it or not. A + * frame that does not reach it has no ring for it. Rounding the step down makes for `count` to + * `ceil(2.5 × count)` rings, and the callout can add one: 5 to 14 for a count of 5. */ export function distanceRings(reach: number, count: number, callout: number): number[] { const step = roundLengthAtMost(reach / count); From 7ab92e61a1cb2ab3ee2b593ea364265ca8c3df1f Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 13:50:47 +0200 Subject: [PATCH 05/11] 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 b7f277ea04714a46cf6b40ad7a59818b12676cfc Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 14:35:17 +0200 Subject: [PATCH 06/11] Answer the review: a short answer makes more survivors, and must not be skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things this got wrong. The direction: truncating Gaia leaves the HYG rows whose counterpart it dropped without one, so survivors rise — 10 886 today, 12 711 at half the rows, 16 258 at a third — which the comment claimed was the other way, and which decides whether the 15 000 ceiling can be leaned on at all (it catches a truncation past about two thirds, and nothing shallower). The throw: `fetchStars` catches everything a source throws and skips it, so a truncated CSV was reported as "the archive was unreachable" one step after `writeStarAssets` had already overwritten the published catalogue. Marked with `GaiaAnswerError` and rethrown there, so an answer that cannot be worked with fails the run where it happened. Measured end to end in a throwaway working directory, 300 000 rows in the cache: fails, names the cache file to delete, assets untouched. With the rethrow taken back out again: assets written, then "the archive was unreachable". The row limit: `rows.length >= ROW_LIMIT` is true for every reduced ETL_GAIA_ROW_LIMIT, so the tripwire fired on exactly the deliberate slice the override exists for — and told the operator to raise it. Gated on the same flag as its neighbour. `ETL_GAIA_ROW_LIMIT=20000` now runs through; without the gate it dies on the limit it was given. Also: the row floor names the one cache file it is about rather than a glob that takes the Hipparcos cross-match with it, and says an edited query is a third reason it can fire — DEFAULT_QUERY_ROWS now sits under the query it counts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- tools/etl/build.ts | 7 +++-- tools/etl/fetchStars.ts | 11 ++++++-- tools/etl/sources/gaia.ts | 59 ++++++++++++++++++++++++--------------- 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/tools/etl/build.ts b/tools/etl/build.ts index 6f11c09..5ca1709 100644 --- a/tools/etl/build.ts +++ b/tools/etl/build.ts @@ -73,8 +73,11 @@ function validateStars(stars: StarRecord[]): void { * lacks: bright stars it saturates on, red dwarfs past its magnitude cut. So the headroom left to * the ceiling tracks the gap between those two cutoffs as much as Gaia's completeness. * - * This bounds a merge that went wrong. It cannot bound a Gaia download that came back short: that - * makes *fewer* survivors, not more, and is guarded where it can be seen, in `fetchGaiaStars`. + * This bounds a merge that went wrong, and — loosely — a Gaia download that came back short: a + * truncated answer leaves the HYG rows whose counterpart it dropped without one, so survivors go + * *up*, not down. Measured against the published catalogue: 10 886 today, 11 004 at nine tenths of + * the rows, 12 711 at half, 16 258 at a third. So this ceiling only catches a truncation past about + * two thirds, and `fetchGaiaStars` catches the shallower ones with its own row floor. */ const MAX_UNMERGED_TWINS = 100; const MAX_HYG_SURVIVORS = 15_000; diff --git a/tools/etl/fetchStars.ts b/tools/etl/fetchStars.ts index 1f2eb66..0225fba 100644 --- a/tools/etl/fetchStars.ts +++ b/tools/etl/fetchStars.ts @@ -3,7 +3,7 @@ import { writeFileSync } from 'node:fs'; import { mergeStarCatalogues, placementDistancePc } from '../../src/app/shared/astro/star-merge'; import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog'; import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model'; -import { fetchGaiaDistancesByHip } from './sources/gaia'; +import { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia'; import { positionalSources } from './sources/registry'; import { PARALLAX_PRECISION_MAS } from './sources/star-sources'; import { parseCsvObjects, parseOptionalNumber } from './lib/csv'; @@ -142,7 +142,9 @@ export async function fetchStars(): Promise { * * A source that cannot be reached is reported and skipped here rather than thrown, so a run still * gets as far as validation and says what it has. Whether that may be published is decided - * there: `validateMerge` in build.ts refuses a catalogue Gaia contributed nothing to. + * there: `validateMerge` in build.ts refuses a catalogue Gaia contributed nothing to. A source + * that answered with something unusable ({@link GaiaAnswerError}) is a different matter, and stops + * the run where it happened rather than being reported later as an outage. */ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise { const others = positionalSources().filter((source) => source.id !== 'hyg'); @@ -160,6 +162,11 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise { // Keyed by the whole request, so a response cached for other columns, another order, or // another endpoint can never be mistaken for this one — the cache records only that some // response arrived, not what it answered. - const csv = await fetchTextCached(url, `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`); + const cacheKey = `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`; + const csv = await fetchTextCached(url, cacheKey); const rows = parseCsvObjects(csv); const jobsQuery = DISTANCE_CUTOFF_PC === DEFAULT_DISTANCE_CUTOFF_PC && MAGNITUDE_LIMIT === DEFAULT_MAGNITUDE_LIMIT && ROW_LIMIT === DEFAULT_ROW_LIMIT; if (jobsQuery && rows.length < DEFAULT_QUERY_ROWS * MIN_ROW_SHARE) { - throw new Error( - `Gaia returned ${rows.length} rows, not the ~${DEFAULT_QUERY_ROWS} this query holds — the answer was cut short, ` + - 'or was an error page served with a 200; delete tools/etl/.cache/gaia-dr3-*.csv once the archive answers properly' + throw new GaiaAnswerError( + `Gaia returned ${rows.length} rows, not the ~${DEFAULT_QUERY_ROWS} this query holds — the answer was cut short, it was an error page ` + + `served with a 200, or the query was edited without updating DEFAULT_QUERY_ROWS; delete tools/etl/.cache/${cacheKey} once the archive answers properly` ); } - if (rows.length >= ROW_LIMIT) { - throw new Error(`Gaia returned the query's own ${ROW_LIMIT}-row limit, so it is the limit deciding what the map holds; raise ETL_GAIA_ROW_LIMIT.`); + if (jobsQuery && rows.length >= ROW_LIMIT) { + throw new GaiaAnswerError(`Gaia returned the query's own ${ROW_LIMIT}-row limit, so it is the limit deciding what the map holds; raise ETL_GAIA_ROW_LIMIT.`); } const stars: StarRecord[] = []; From 1a53f26474aef61847f8a106828a16bf5dcfc0af Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 14:46:47 +0200 Subject: [PATCH 07/11] 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 7fba48c80834550486dc919faf41c64ad220c6a7 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 15:01:05 +0200 Subject: [PATCH 08/11] Answer the review: size the rings to the band the frame covers, and clear the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rings are centred on the Sun and the frame need not be. Sizing their step from how far the frame reaches — 210 pc for a star at 190 with the camera 20 pc back — gives 20 pc rings at 180 and 200, both outside a frame 19 pc deep, so a view away from the Sun still had no ring on it and no ladder of labels either. `distanceRings` now takes the span the frame covers rather than its far edge, and the step is a fifth of that: 5 pc rings from 165 to 210 for the same view. Measured in the app, centred on a star 187 pc out in the galactic plane: 4 ring labels drawn 20 pc above the plane and 7 from 2 pc, against 1 and none before. The clearance was a radius around the anchor, and a label is a line of text hanging 135 px to one side of its anchor: at 0.065 NDC apart, past the radius, "50 pc" printed inside "Alpha Centauri". It is now tested against the span the name occupies, on the side it hangs, with the radius kept for the pair whose text runs the other way. Also from the review: the ladder in the clearance test was built at exactly the constant it tests, so 1057 of 2000 camera poses would have decided it by float round-trip error — the rungs now sit 0.02 either side of the rule. And two comments that were wrong: a frame one step short of the survey edge does get its callout, and CSS2DRenderer hides a label behind the camera rather than drawing it at the page edge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 29 ++++++++++++++- .../galaxy-system-scene.component.ts | 37 +++++++++++++++---- src/app/shared/format/scale-bar.spec.ts | 27 ++++++++++---- src/app/shared/format/scale-bar.ts | 29 ++++++++++----- 4 files changed, 97 insertions(+), 25 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 70d5d66..b79dc1f 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 @@ -441,6 +441,10 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { await advanceFrames(engine, 0.3); expect(underPerspective.at(-1)).toBeGreaterThanOrEqual(200); + // And one of them has to cross the frame, which is a band about 19 pc either side of 200 pc: + // rings out to 220 at a step sized to all 220 are 180 and 200, both of them off screen. + const halfHeight = engine.visibleHalfHeight(20); + expect(underPerspective.some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true); // The plan view's wheel moves the frame rather than the camera, so "how far out the camera // is" means something else there; what the rings have to cover does not. expect([...component.localGridRadii]).toEqual(underPerspective); @@ -470,8 +474,10 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { const camera = engine.getCamera(); camera.updateMatrixWorld(true); const at = (x: number, y: number) => new THREE.Vector3(x, y, 0.5).unproject(camera); + // Rungs at a twentieth of the screen: well inside the separation two names would keep, and + // well outside the clearance a ring label keeps from a name, so neither test is a coin toss. const near = at(0.1, 0.1); - const nextRungUp = at(0.1, 0.16); + const nextRungUp = at(0.1, 0.18); const offScreen = at(1.6, 0.1); const ladder: LabeledPoint[] = [ { id: 'ring-50', name: '50 pc', x: near.x, y: near.y, z: near.z }, @@ -486,6 +492,27 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(component.ringLabelsInTheClear(ladder, camera, [star]).map((label) => label.id)).toEqual(['ring-100']); }); + it('stays out of the text of a name, not just off its point', () => { + const component = fixture.componentInstance as unknown as { + ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[]; + viewportAspect(): number; + }; + const camera = engine.getCamera(); + camera.updateMatrixWorld(true); + const aspect = component.viewportAspect(); + const at = (x: number, y: number) => new THREE.Vector3(x / aspect, y, 0.5).unproject(camera); + // A hand's breadth apart on screen — past any clearance around the point — and on the same + // line, with the name's text running right through where the ring label starts. + const ring = at(0.125, -0.123); + const rung: LabeledPoint = { id: 'ring-50', name: '50 pc', x: ring.x, y: ring.y, z: ring.z }; + const beside = at(0.06, -0.12); + const rightHand: LabeledPoint = { id: 7, name: 'Alpha Centauri', side: 'right', x: beside.x, y: beside.y, z: beside.z }; + + expect(component.ringLabelsInTheClear([rung], camera, [rightHand])).toEqual([]); + // The same name hanging the other way leaves that space empty, and the rung with it. + expect(component.ringLabelsInTheClear([rung], camera, [{ ...rightHand, side: 'left' }])).toEqual([rung]); + }); + it('places the ring labels with the star names rather than over them', async () => { const component = fixture.componentInstance as unknown as GridScene; const update = vi.spyOn(StarLabelOverlay.prototype, 'update'); 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 2250ae1..5cf5050 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -72,8 +72,9 @@ const LABEL_MIN_SEPARATION_NDC = 0.12; const LABEL_EDGE_NDC = 0.7; /** * How far a ring label has to sit from a star's name, in NDC — half what two star names keep - * between them. A ring label is one short line, and the rungs of its ladder are a twentieth of the - * screen apart, so the full separation would have one name clear three rungs. + * between them, as a clearance around the anchor and as the height of the row its text occupies. + * A ring label is one short line, and the rungs of its ladder are a twentieth of the screen apart, + * so the full separation would have one name clear three rungs. */ const RING_LABEL_CLEARANCE_NDC = LABEL_MIN_SEPARATION_NDC / 2; /** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */ @@ -621,7 +622,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { centre: new THREE.Vector3(centre.x, centre.y, centre.z), emphasisRadii: [SUN_GALACTOCENTRIC_RADIUS_PC] }); - this.setLocalGridRadii(distanceRings(GALAXY_OVERVIEW_POSITION.length(), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC)); + this.setLocalGridRadii(distanceRings(0, GALAXY_OVERVIEW_POSITION.length(), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC)); // A fixed set rather than whatever is currently labelled: a tether that appears and vanishes // as the camera drifts reads as a glitch. this.tethers = new TetherField(TETHERED_STAR_COUNT); @@ -925,7 +926,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * Not held apart from each other, as the star names are: they are a ladder up one ray, a few * hundredths of the screen apart, and reading them in order is the point. What they must not do * is sit on a star's name, which is worth more than a distance — or be handed to the overlay - * from behind or beside the camera, which draws them at the edge of the page rather than not at all. + * from beside the camera, which CSS2DRenderer places past the edge of the container rather than + * hiding, since all it tests is depth. + * + * A name is a line of text hanging to one side of its point, about 135 px of it, not the point: + * two anchors a tenth of the screen apart still print one inside the other. So the test is + * against the span the name occupies, with the anchors' own clearance kept for the pair whose + * text runs the other way. */ private ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: SceneCamera, stars: readonly LabeledPoint[]): LabeledPoint[] { const projected = new THREE.Vector3(); @@ -934,10 +941,21 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const outside = projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1; return outside ? null : new THREE.Vector2(projected.x * this.viewportAspect(), projected.y); }; - const taken = stars.map(onScreen).filter((point): point is THREE.Vector2 => point !== null); + const taken = stars + .map((star) => ({ at: onScreen(star), side: star.side })) + .filter((name): name is { at: THREE.Vector2; side: LabelSide | undefined } => name.at !== null) + .map(({ at, side }) => ({ at, from: side === 'left' ? at.x - LABEL_REACH_NDC : at.x, to: side === 'left' ? at.x : at.x + LABEL_REACH_NDC })); return candidates.filter((label) => { const point = onScreen(label); - return point !== null && !taken.some((other) => other.distanceTo(point) < RING_LABEL_CLEARANCE_NDC); + // Ring labels hang right, as `applyPresentation` leaves anything with no side of its own. + return ( + point !== null && + !taken.some( + (name) => + name.at.distanceTo(point) < RING_LABEL_CLEARANCE_NDC || + (Math.abs(name.at.y - point.y) < RING_LABEL_CLEARANCE_NDC && name.from < point.x + LABEL_REACH_NDC && point.x < name.to) + ) + ); }); } @@ -962,7 +980,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // vertex of the new ones, and the set changes on any zoom that crosses a round step. if (!isGalactic && this.display().grid) { const orbitPc = this.engine.currentProjection === 'perspective' ? camera.position.distanceTo(target) : this.effectiveDistance(camera); - const radii = distanceRings(target.length() + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); + // Half the frame's diagonal, at the depth it is centred on: how near the Sun the frame + // reaches, as well as how far. A step sized to the far edge alone is no use to a frame that + // does not contain the Sun — 20 pc rings for a view of a 19 pc band at 190 pc drew none of + // them on screen, and the ladder of labels went with them. + const frameRadiusPc = this.engine.visibleHalfHeight(orbitPc) * Math.hypot(1, this.viewportAspect()); + const radii = distanceRings(Math.max(0, target.length() - frameRadiusPc), target.length() + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); if (radii.join() !== this.localGridRadii.join()) { this.setLocalGridRadii(radii); } diff --git a/src/app/shared/format/scale-bar.spec.ts b/src/app/shared/format/scale-bar.spec.ts index f673386..9fda567 100644 --- a/src/app/shared/format/scale-bar.spec.ts +++ b/src/app/shared/format/scale-bar.spec.ts @@ -28,30 +28,41 @@ describe('distanceRings', () => { // The opening view sits about 307 pc from the Sun: the rings the map always had, with the // survey edge at the fifth, and on out past the camera for the stars now drawn beyond it. it('reaches past the camera from the opening view', () => { - expect(distanceRings(307, 5, 250)).toEqual([50, 100, 150, 200, 250, 300, 350]); + expect(distanceRings(0, 307, 5, 250)).toEqual([50, 100, 150, 200, 250, 300, 350]); }); it('closes in with the camera', () => { - expect(distanceRings(20, 5, 250)).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]); - expect(distanceRings(1, 5, 250)).toEqual([0.2, 0.4, 0.6, 0.8, 1]); + expect(distanceRings(0, 20, 5, 250)).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]); + expect(distanceRings(0, 1, 5, 250)).toEqual([0.2, 0.4, 0.6, 0.8, 1]); }); // Near Mirfak the camera is 155 pc out; rounding the step down to 20 pc must not leave the // rings stopping at 100. it('covers the whole distance whatever the rounding', () => { - expect(distanceRings(155, 5, 250)).toEqual([20, 40, 60, 80, 100, 120, 140, 160]); + expect(distanceRings(0, 155, 5, 250)).toEqual([20, 40, 60, 80, 100, 120, 140, 160]); + }); + + // A star 190 pc out seen from 20 pc away: the frame is a band about 19 pc either side of it and + // the Sun is nowhere in it. Sized to the 210 pc it reaches, the step would be 20 pc and the + // nearest rings — 180 and 200 — would both miss the frame. + it('spaces the rings for a frame that does not hold the Sun', () => { + const radii = distanceRings(171, 210, 5, 250); + + expect(radii).toEqual([170, 175, 180, 185, 190, 195, 200, 205, 210]); + expect(radii.some((radius) => Math.abs(radius - 190) < 19)).toBe(true); }); it('marks the callout among rings the step does not land on', () => { - expect(distanceRings(1000, 5, 250)).toEqual([200, 250, 400, 600, 800, 1000]); + expect(distanceRings(0, 1000, 5, 250)).toEqual([200, 250, 400, 600, 800, 1000]); }); - it('leaves the callout out when it is past the last ring', () => { - expect(distanceRings(100, 5, 250)).toEqual([20, 40, 60, 80, 100]); + it('leaves the callout out when it is past the last ring, or behind the first', () => { + expect(distanceRings(0, 100, 5, 250)).toEqual([20, 40, 60, 80, 100]); + expect(distanceRings(400, 440, 5, 250)).toEqual([400, 405, 410, 415, 420, 425, 430, 435, 440]); }); it('draws no rings for a camera with no distance', () => { - expect(distanceRings(0, 5, 250)).toEqual([]); + expect(distanceRings(0, 0, 5, 250)).toEqual([]); }); }); diff --git a/src/app/shared/format/scale-bar.ts b/src/app/shared/format/scale-bar.ts index ff10fbd..f5b1e54 100644 --- a/src/app/shared/format/scale-bar.ts +++ b/src/app/shared/format/scale-bar.ts @@ -16,20 +16,31 @@ export function roundLengthAtMost(value: number): number | null { } /** - * Rings at a round step of about `reach / count`, out to `reach` or just past it, plus `callout` - * where it falls between the first ring and the last: the grid's own radii are round, and the one - * radius that means something in its own right is marked whether the step lands on it or not. A - * frame that does not reach it has no ring for it. Rounding the step down makes for `count` to - * `ceil(2.5 × count)` rings, and the callout can add one: 5 to 14 for a count of 5. + * Rings across the span from `nearest` to `reach`, at a round step of about a `count`th of it, + * plus `callout` where it falls between the first ring and the last: the grid's own radii are + * round, and the one radius that means something in its own right is marked whether the step lands + * on it or not. A frame short of it by less than one step still gets it, since the last ring + * overshoots `reach`; one that stops well short does not. + * + * Two numbers rather than one because these rings are centred on a fixed point — the Sun — and a + * frame need not be. Looking at something 200 pc out from 20 pc away, what is on screen is a band + * 200 pc wide at its narrowest and nowhere near the Sun; a step sized to the whole 220 puts every + * ring off the frame. The span is what the frame covers, so the step is what it can resolve. + * + * Rounding the step down makes for `count` to `ceil(2.5 × count)` rings, and the callout can add + * one: 5 to 14 for a count of 5. */ -export function distanceRings(reach: number, count: number, callout: number): number[] { - const step = roundLengthAtMost(reach / count); +export function distanceRings(nearest: number, reach: number, count: number, callout: number): number[] { + const step = roundLengthAtMost((reach - nearest) / count); if (step === null) { return []; } + // The ring just inside the near edge of the span, so the band is crossed rather than started at. + const first = Math.max(1, Math.floor(nearest / step)); + const last = Math.ceil(reach / step); // `toPrecision` clears the binary noise of stepping by a tenth: 0.1 × 3 is 0.30000000000000004. - const radii = Array.from({ length: Math.ceil(reach / step) }, (_, index) => Number((step * (index + 1)).toPrecision(12))); - if (callout > step && callout < radii[radii.length - 1] && !radii.includes(callout)) { + const radii = Array.from({ length: last - first + 1 }, (_, index) => Number((step * (first + index)).toPrecision(12))); + if (callout > radii[0] && callout < radii[radii.length - 1] && !radii.includes(callout)) { radii.push(callout); radii.sort((a, b) => a - b); } From 1a5785fb63a2c7d2dab0e47f6eae9cc1894d7b9a Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 15:46:42 +0200 Subject: [PATCH 09/11] Keep the row cap live for the queries that can reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gating it on `jobsQuery` switched it off for every override that *widens* the query — which is the only way to fill `select top N` at all. `ETL_GAIA_MAGNITUDE_LIMIT=14` asks for 500 000 rows, the sky holds more, and the answer is the limit rather than the filters: exactly what the tripwire is for, and it no longer fired. It now reads the row limit itself, so only a deliberately smaller slice is silent. Measured with a synthetic answer of exactly 500 000 rows in the cache, under the key the widened query hashes to: refused. With the `jobsQuery` gate back, the same run keeps 500 000 Gaia stars and goes on to publish them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- tools/etl/sources/gaia.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/etl/sources/gaia.ts b/tools/etl/sources/gaia.ts index 1df52bc..bdd6626 100644 --- a/tools/etl/sources/gaia.ts +++ b/tools/etl/sources/gaia.ts @@ -132,7 +132,10 @@ export async function fetchGaiaStars(): Promise { `served with a 200, or the query was edited without updating DEFAULT_QUERY_ROWS; delete tools/etl/.cache/${cacheKey} once the archive answers properly` ); } - if (jobsQuery && rows.length >= ROW_LIMIT) { + // Not gated on `jobsQuery` like the floor above it: the only ways to reach this cap are the + // overrides that *widen* the query, and they are exactly when it is worth saying. What it must + // not fire on is a deliberately smaller slice, where filling the limit is the whole point. + if (ROW_LIMIT >= DEFAULT_ROW_LIMIT && rows.length >= ROW_LIMIT) { throw new GaiaAnswerError(`Gaia returned the query's own ${ROW_LIMIT}-row limit, so it is the limit deciding what the map holds; raise ETL_GAIA_ROW_LIMIT.`); } const stars: StarRecord[] = []; From 337602f6064029853595af610ab2ae30fdf5a057 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 15:51:38 +0200 Subject: [PATCH 10/11] 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) { From 0c5efec5050e86561d6de411292d95edca2d22ff Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 18 Sep 2026 15:57:44 +0200 Subject: [PATCH 11/11] Measure the ring span along the plane the rings lie in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The span went to `distanceRings` as the target's straight-line distance from the Sun, but a ring of radius r passes within |r - p| of the view's centre, where p is how far out that centre is *along* the galactic plane. For a target above the plane the two differ by its height, so the band was centred on a radius no ring has — and `ringLabels` picks its bearing by comparing its own in-plane distance against the innermost ring, a comparison the new first ring quietly broke. Two comments and a constant, from the same review. A frame short of the survey edge gets its callout only when its last ring overshoots it: 245 pc does, 235 pc does not, which is now a test rather than a sentence. The ring count can reach 16, not 14, now that the span need not start at the Sun. And a ring label was measured as 135 px of star name when "50 pc" is a third of that, which rejected rungs a hand's breadth clear of the name: RING_LABEL_REACH_NDC, 0.23, is the widest of them — "1.5 kpc" with "Survey edge" under it. Three mutants, three caught. Measured again in the app: unchanged for a star in the plane (4 labels at 20 pc above it, 7 at 2 pc), and the rings now follow the plane for one 195 pc above it rather than ringing a place the grid does not reach. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 39 ++++++++++++++++--- .../galaxy-system-scene.component.ts | 16 +++++++- src/app/shared/format/scale-bar.spec.ts | 4 ++ src/app/shared/format/scale-bar.ts | 10 +++-- 4 files changed, 58 insertions(+), 11 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 b79dc1f..9966290 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 @@ -13,6 +13,7 @@ import { NavigationStore } from '../../shared/state/navigation.store'; import { LinkBudget } from '../../shared/astro/jump-links'; import { HudDisplay } from '../hud/hud-dock.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; +import { galacticNormal } from './grid-plane'; import { JumpLinkRenderer } from './jump-link-renderer'; import { StarFieldRenderer } from './star-field-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; @@ -429,9 +430,12 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { it('sizes the rings by how far the frame reaches from the Sun, under either projection', async () => { const component = fixture.componentInstance as unknown as GridScene; const camera = engine.getCamera(); - // Centred on a star 200 pc out, seen from 20 pc away: the rings have to reach it. - component.controls.target.set(200, 0, 0); - camera.position.set(200, 0, 20); + // Centred on a point 200 pc out along the galactic plane — where the rings are — seen from + // 20 pc above it. The rings have to reach it, and one of them has to cross the frame. + const normal = galacticNormal(); + const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200); + component.controls.target.copy(centre); + camera.position.copy(centre).addScaledVector(normal, 20); component.controls.update(); await advanceFrames(engine, 0.3); const underPerspective = [...component.localGridRadii]; @@ -441,8 +445,8 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { await advanceFrames(engine, 0.3); expect(underPerspective.at(-1)).toBeGreaterThanOrEqual(200); - // And one of them has to cross the frame, which is a band about 19 pc either side of 200 pc: - // rings out to 220 at a step sized to all 220 are 180 and 200, both of them off screen. + // The frame is a band about 19 pc either side of 200 pc: rings out to 220 at a step sized to + // all 220 are 180 and 200, both of them off screen. const halfHeight = engine.visibleHalfHeight(20); expect(underPerspective.some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true); // The plan view's wheel moves the frame rather than the camera, so "how far out the camera @@ -450,6 +454,22 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect([...component.localGridRadii]).toEqual(underPerspective); }); + it('measures the span in the plane the rings lie in, not through it', async () => { + const component = fixture.componentInstance as unknown as GridScene; + const camera = engine.getCamera(); + // The same 200 pc out along the plane, but lifted 150 pc above it: 250 pc from the Sun as the + // crow flies, and still 200 pc out among the rings, which is the distance they are drawn at. + const normal = galacticNormal(); + const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200).addScaledVector(normal, 150); + component.controls.target.copy(centre); + camera.position.copy(centre).addScaledVector(normal, 20); + component.controls.update(); + await advanceFrames(engine, 0.3); + + const halfHeight = engine.visibleHalfHeight(20); + expect([...component.localGridRadii].some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true); + }); + it('leaves the rings alone while the grid is not drawn', async () => { const component = fixture.componentInstance as unknown as GridScene; const camera = engine.getCamera(); @@ -511,6 +531,15 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(component.ringLabelsInTheClear([rung], camera, [rightHand])).toEqual([]); // The same name hanging the other way leaves that space empty, and the rung with it. expect(component.ringLabelsInTheClear([rung], camera, [{ ...rightHand, side: 'left' }])).toEqual([rung]); + + // And a rung to the left of a name keeps its place: "50 pc" is a third of a star name's + // width, so it ends well before the name starts, whatever the anchors' spacing suggests. + const centred = at(0, 0); + const spanning: LabeledPoint = { id: 8, name: 'Alnitak', side: 'right', x: centred.x, y: centred.y, z: centred.z }; + const toTheLeft = at(-0.25, 0.02); + const clearRung: LabeledPoint = { id: 'ring-100', name: '100 pc', x: toTheLeft.x, y: toTheLeft.y, z: toTheLeft.z }; + + expect(component.ringLabelsInTheClear([clearRung], camera, [spanning])).toEqual([clearRung]); }); it('places the ring labels with the star names rather than over them', 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 5cf5050..15e7585 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -79,6 +79,12 @@ const LABEL_EDGE_NDC = 0.7; const RING_LABEL_CLEARANCE_NDC = LABEL_MIN_SEPARATION_NDC / 2; /** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */ const LABEL_REACH_NDC = 0.3; +/** + * The same for a ring label, which is shorter: "1.5 kpc" with "Survey edge" under it is the widest + * of them, about 100px at 1440. Measuring those as a star name's width rejected rungs a hand's + * breadth clear of it. + */ +const RING_LABEL_REACH_NDC = 0.23; /** * How long the range control has to be still before the graph is rebuilt at its value, since a drag * emits per pixel; and how often at most a view on the move gets a graph for its new drawn stars. @@ -953,7 +959,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { !taken.some( (name) => name.at.distanceTo(point) < RING_LABEL_CLEARANCE_NDC || - (Math.abs(name.at.y - point.y) < RING_LABEL_CLEARANCE_NDC && name.from < point.x + LABEL_REACH_NDC && point.x < name.to) + (Math.abs(name.at.y - point.y) < RING_LABEL_CLEARANCE_NDC && name.from < point.x + RING_LABEL_REACH_NDC && point.x < name.to) ) ); }); @@ -985,7 +991,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // does not contain the Sun — 20 pc rings for a view of a 19 pc band at 190 pc drew none of // them on screen, and the ladder of labels went with them. const frameRadiusPc = this.engine.visibleHalfHeight(orbitPc) * Math.hypot(1, this.viewportAspect()); - const radii = distanceRings(Math.max(0, target.length() - frameRadiusPc), target.length() + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); + // Measured in the plane the rings lie in, not through it: a ring of radius r passes within + // `|r - p|` of the view's centre, where p is how far out the centre is *along the plane*. For + // a target above it the two differ by its height, which would put the band around a radius no + // ring has — and `ringLabels` compares its own in-plane bearing against the innermost. + const normal = galacticNormal(); + const inPlanePc = target.clone().addScaledVector(normal, -target.dot(normal)).length(); + const radii = distanceRings(Math.max(0, inPlanePc - frameRadiusPc), inPlanePc + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC); if (radii.join() !== this.localGridRadii.join()) { this.setLocalGridRadii(radii); } diff --git a/src/app/shared/format/scale-bar.spec.ts b/src/app/shared/format/scale-bar.spec.ts index 9fda567..2913a6d 100644 --- a/src/app/shared/format/scale-bar.spec.ts +++ b/src/app/shared/format/scale-bar.spec.ts @@ -58,6 +58,10 @@ describe('distanceRings', () => { it('leaves the callout out when it is past the last ring, or behind the first', () => { expect(distanceRings(0, 100, 5, 250)).toEqual([20, 40, 60, 80, 100]); + // Short of the survey edge by less than one step is not the rule — the last ring is: 245 pc + // overshoots to 260 and gets it, 235 pc stops at 240 and does not, on the same 20 pc step. + expect(distanceRings(0, 245, 5, 250)).toContain(250); + expect(distanceRings(0, 235, 5, 250)).not.toContain(250); expect(distanceRings(400, 440, 5, 250)).toEqual([400, 405, 410, 415, 420, 425, 430, 435, 440]); }); diff --git a/src/app/shared/format/scale-bar.ts b/src/app/shared/format/scale-bar.ts index f5b1e54..96bb777 100644 --- a/src/app/shared/format/scale-bar.ts +++ b/src/app/shared/format/scale-bar.ts @@ -19,16 +19,18 @@ export function roundLengthAtMost(value: number): number | null { * Rings across the span from `nearest` to `reach`, at a round step of about a `count`th of it, * plus `callout` where it falls between the first ring and the last: the grid's own radii are * round, and the one radius that means something in its own right is marked whether the step lands - * on it or not. A frame short of it by less than one step still gets it, since the last ring - * overshoots `reach`; one that stops well short does not. + * on it or not. A frame that stops short of it gets it only when the last ring — the first multiple + * of `step` at or past `reach` — is past it: reach 245 with a 20 pc step gets it, reach 235 does + * not, since its last ring is 240. * * Two numbers rather than one because these rings are centred on a fixed point — the Sun — and a * frame need not be. Looking at something 200 pc out from 20 pc away, what is on screen is a band * 200 pc wide at its narrowest and nowhere near the Sun; a step sized to the whole 220 puts every * ring off the frame. The span is what the frame covers, so the step is what it can resolve. * - * Rounding the step down makes for `count` to `ceil(2.5 × count)` rings, and the callout can add - * one: 5 to 14 for a count of 5. + * Rounding the step down, over a span that need not start at the Sun, makes for `count` to + * `ceil(2.5 × count) + 2` rings — `ceil(reach / step) - floor(nearest / step) + 1` — and the + * callout can add one: 5 to 16 for a count of 5. */ export function distanceRings(nearest: number, reach: number, count: number, callout: number): number[] { const step = roundLengthAtMost((reach - nearest) / count);