Rank search results instead of taking the first eight

Search scanned the index in construction order — 8750 stars, then 18 bodies,
then 6319 exoplanets — collected substring matches, and stopped at eight. With
no scoring, position in the index decided everything.

Typing "Io" returned eight stars named "Iot Cas", "Iot Eri" and so on, and never
reached the moon Io — despite Io being an *exact* match, because the scan had
already filled up 8750 entries before the bodies begin. "Kepler" returned
Kepler-939 b, Kepler-1292 b, Kepler-223 d and five more in whatever order the
archive happened to list them.

Everything is now scored before anything is taken, so where an entry sits in the
index cannot hide a better match. Exact beats prefix beats word-start beats
substring, with the gaps wide enough that a worse kind of match can never
outrank a better one. Ties break on kind — the eighteen solar-system bodies
first, then stars, then exoplanets, so "Proxima" offers the star before its own
planets — then on name length, then alphabetically, so the order is fully
determined rather than inherited from the input. Matching is punctuation
insensitive as well as case insensitive, so "gl357" finds "Gl 357".

"Io" now returns the moon first. "Kepler" returns Kepler-4 b through Kepler-9 d.

The index is pre-normalised once on load rather than per keystroke. Lowercasing,
stripping punctuation and splitting 15,000 names on every character typed costs
about 11 ms, which is most of a frame, and search shares a thread with the render
loop — so it stuttered the scene while typing. Precomputing takes a broad query
down to 2.5 ms and a narrow one to 0.5 ms, for one 15 ms build during the
existing data load.

Adds the first tests this component has had, alongside the ranking's own.

Tests: 237 passing, up from 206. Verified in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-04 11:55:34 +00:00
parent f2c77fb5ad
commit dad4b93e8a
4 changed files with 441 additions and 25 deletions
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import { buildSearchIndex, rankSearchResults, scoreSearchMatch, SearchEntry } from './search-ranking';
function star(name: string): SearchEntry {
return { kind: 'star', name, subtitle: 'G2V', starId: name.length };
}
function body(name: string): SearchEntry {
return { kind: 'body', name, subtitle: 'moon', bodyId: name };
}
function exoplanet(name: string): SearchEntry {
return { kind: 'exoplanet', name, subtitle: 'host', bodyId: name };
}
function rank(entries: readonly SearchEntry[], query: string, limit = 8): string[] {
return rankSearchResults(buildSearchIndex(entries), query, limit).map((entry) => entry.name);
}
describe('scoreSearchMatch', () => {
it('ranks an exact match above a prefix, a prefix above a word start, and that above a substring', () => {
const exact = scoreSearchMatch('Io', 'Io');
const prefix = scoreSearchMatch('Iot Cas', 'Io');
const wordStart = scoreSearchMatch('Alpha Ionis', 'Io');
const substring = scoreSearchMatch('Bellion', 'io');
expect(exact).toBeGreaterThan(prefix);
expect(prefix).toBeGreaterThan(wordStart);
expect(wordStart).toBeGreaterThan(substring);
expect(substring).toBeGreaterThan(0);
});
it('ignores case', () => {
expect(scoreSearchMatch('Sirius', 'sirius')).toBe(scoreSearchMatch('Sirius', 'Sirius'));
});
it('ignores punctuation and spacing for an exact match', () => {
// HYG writes "Gl 357"; a user may well type "gl357".
expect(scoreSearchMatch('Gl 357', 'gl357')).toBe(scoreSearchMatch('Gl 357', 'Gl 357'));
});
it('treats a hyphenated part as its own word', () => {
// "Kepler-9 c" should be reachable by its designation as well as its catalogue name.
expect(scoreSearchMatch('Kepler-9 c', '9')).toBeGreaterThan(0);
});
it('does not match an unrelated name', () => {
expect(scoreSearchMatch('Sirius', 'zzz')).toBe(0);
});
it('does not match an empty query', () => {
expect(scoreSearchMatch('Sirius', '')).toBe(0);
expect(scoreSearchMatch('Sirius', ' ')).toBe(0);
});
});
describe('rankSearchResults', () => {
it('surfaces an exact match that the old index-order scan could never reach', () => {
// The moon Io sits behind 8750 stars in the index, so a scan that stopped at the first
// 8 substring hits returned only stars named "Iot ..." and never reached it.
const entries = [...Array.from({ length: 30 }, (_, i) => star(`Iot Star ${i}`)), body('Io')];
expect(rank(entries, 'Io')[0]).toBe('Io');
});
it('keeps the rest of the matches after the exact one', () => {
const entries = [star('Iot Cas'), body('Io'), star('Iot Boo')];
expect(rank(entries, 'Io')).toEqual(['Io', 'Iot Boo', 'Iot Cas']);
});
it('prefers a prefix match over a mid-name one', () => {
expect(rank([star('Bellion'), star('Ionis')], 'io')).toEqual(['Ionis', 'Bellion']);
});
it('orders equally-scored matches by kind, bodies first then stars then exoplanets', () => {
const entries = [exoplanet('Cen x'), star('Cen y'), body('Cen z')];
expect(rank(entries, 'Cen')).toEqual(['Cen z', 'Cen y', 'Cen x']);
});
it('puts the host star ahead of its own planets', () => {
const entries = [exoplanet('Proxima Cen b'), exoplanet('Proxima Cen d'), star('Proxima Centauri')];
expect(rank(entries, 'Proxima')[0]).toBe('Proxima Centauri');
});
it('breaks remaining ties by name length, then alphabetically', () => {
const entries = [exoplanet('Kepler-1292 b'), exoplanet('Kepler-9 c'), exoplanet('Kepler-9 b'), exoplanet('Kepler-15 b')];
expect(rank(entries, 'Kepler')).toEqual(['Kepler-9 b', 'Kepler-9 c', 'Kepler-15 b', 'Kepler-1292 b']);
});
it('is independent of the order entries were indexed in', () => {
const entries = [star('Iot Cas'), body('Io'), exoplanet('Iota b')];
expect(rank(entries, 'Io')).toEqual(rank([...entries].reverse(), 'Io'));
});
it('respects the limit', () => {
const entries = Array.from({ length: 50 }, (_, i) => star(`Test ${i}`));
expect(rank(entries, 'Test', 8)).toHaveLength(8);
});
it('returns nothing for a limit of zero or less', () => {
expect(rank([star('Sirius')], 'Sirius', 0)).toEqual([]);
expect(rank([star('Sirius')], 'Sirius', -1)).toEqual([]);
});
it('returns nothing for an empty query rather than everything', () => {
expect(rank([star('Sirius'), body('Io')], '')).toEqual([]);
expect(rank([star('Sirius'), body('Io')], ' ')).toEqual([]);
});
it('returns nothing when there is no match', () => {
expect(rank([star('Sirius')], 'zzz')).toEqual([]);
});
it('handles an empty index', () => {
expect(rank([], 'anything')).toEqual([]);
});
it('keeps both stars that genuinely share a name', () => {
// 17 HYG names are shared by two records — binary components are separate, visitable stars.
const entries = [star('Iot Pic'), star('Iot Pic')];
expect(rank(entries, 'Iot Pic')).toHaveLength(2);
});
it('carries the full entry through, not just the name', () => {
const [result] = rankSearchResults(buildSearchIndex([body('Io')]), 'Io', 1);
expect(result).toMatchObject({ kind: 'body', name: 'Io', bodyId: 'Io' });
});
});
describe('buildSearchIndex', () => {
it('preserves every entry', () => {
const entries = [star('A'), body('B'), exoplanet('C')];
expect(buildSearchIndex(entries).map((indexed) => indexed.entry)).toEqual(entries);
});
it('precomputes the forms matching needs', () => {
const [indexed] = buildSearchIndex([star('Alpha Cen-B')]);
expect(indexed.normalizedName).toBe('alpha cen-b');
expect(indexed.compactName).toBe('alphacenb');
expect(indexed.words).toEqual(['alpha', 'cen', 'b']);
});
it('handles an empty list', () => {
expect(buildSearchIndex([])).toEqual([]);
});
});
+139
View File
@@ -0,0 +1,139 @@
export type SearchResultKind = 'star' | 'body' | 'exoplanet';
export interface SearchEntry {
kind: SearchResultKind;
name: string;
subtitle: string;
/** HYG star id, for `kind: 'star'` results. */
starId?: number;
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
bodyId?: string;
}
/**
* How well a name matches, best first. The gaps are what matter: any exact match outranks every
* prefix match, and so on, so a better kind of match can never be crowded out by a worse one.
*/
const MATCH_EXACT = 4;
const MATCH_PREFIX = 3;
const MATCH_WORD_START = 2;
const MATCH_SUBSTRING = 1;
const NO_MATCH = 0;
/**
* Order for results that match equally well. Solar-system bodies are eighteen famous objects
* and win ties outright; a star outranks an exoplanet because searching a name like "Proxima"
* is usually an attempt to reach the system rather than one particular planet in it.
*/
const KIND_PRIORITY: Readonly<Record<SearchResultKind, number>> = {
body: 0,
star: 1,
exoplanet: 2
};
/** Catalogue names separate their parts with spaces, hyphens, underscores and slashes. */
const WORD_SEPARATORS = /[\s\-_/]+/;
function normalize(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
/** Strips everything but letters and digits, so "gj581" can match "GJ 581". */
function compact(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}
/**
* A search entry with its name pre-broken into the forms matching needs.
*
* Built once via {@link buildSearchIndex} rather than derived per keystroke. Normalising 15,000
* names on every character typed costs about 11 ms — most of a frame — and the search runs on
* the same thread as the render loop, so doing it live visibly stutters the scene.
*/
export interface IndexedSearchEntry {
readonly entry: SearchEntry;
readonly normalizedName: string;
readonly compactName: string;
readonly words: readonly string[];
}
export function buildSearchIndex(entries: readonly SearchEntry[]): IndexedSearchEntry[] {
return entries.map((entry) => {
const normalizedName = normalize(entry.name);
return {
entry,
normalizedName,
compactName: compact(entry.name),
words: normalizedName.split(WORD_SEPARATORS)
};
});
}
function scoreIndexed(indexed: IndexedSearchEntry, normalizedQuery: string, compactQuery: string): number {
// Punctuation-insensitive as well as case-insensitive, because the catalogues are
// inconsistent about it: HYG writes "Gl 357" where a user may well type "gl357".
if (indexed.normalizedName === normalizedQuery || indexed.compactName === compactQuery) {
return MATCH_EXACT;
}
if (indexed.normalizedName.startsWith(normalizedQuery)) {
return MATCH_PREFIX;
}
if (indexed.words.some((word) => word.startsWith(normalizedQuery))) {
return MATCH_WORD_START;
}
if (indexed.normalizedName.includes(normalizedQuery)) {
return MATCH_SUBSTRING;
}
return NO_MATCH;
}
/**
* How well `name` matches `query`, or {@link NO_MATCH}. The readable, allocation-per-call form
* of {@link scoreIndexed}, kept for tests and for callers scoring a single name.
*/
export function scoreSearchMatch(name: string, query: string): number {
const normalizedQuery = normalize(query);
if (normalizedQuery === '') {
return NO_MATCH;
}
return scoreIndexed(buildSearchIndex([{ kind: 'star', name, subtitle: '' }])[0], normalizedQuery, compact(query));
}
/**
* The `limit` best matches for `query`, best first.
*
* Replaces a scan that took the first `limit` substring matches in index order — stars, then
* bodies, then exoplanets. With 8750 stars ahead of 18 bodies, that let a worse match hide a
* better one *and* an exact match: searching "Io" returned eight stars named "Iot ..." and
* never reached the moon Io at all, because the scan had already filled up.
*
* Everything is scored before anything is taken, so the best matches win regardless of where
* they sit in the index. Ties break on kind, then on name length — a shorter name containing
* the query is the closer match — and finally alphabetically, so the order is fully determined
* rather than dependent on the input order.
*/
export function rankSearchResults(index: readonly IndexedSearchEntry[], query: string, limit: number): SearchEntry[] {
const normalizedQuery = normalize(query);
if (limit <= 0 || normalizedQuery === '') {
return [];
}
const compactQuery = compact(query);
const scored: { entry: SearchEntry; score: number }[] = [];
for (const indexed of index) {
const score = scoreIndexed(indexed, normalizedQuery, compactQuery);
if (score > NO_MATCH) {
scored.push({ entry: indexed.entry, score });
}
}
scored.sort(
(a, b) =>
b.score - a.score ||
KIND_PRIORITY[a.entry.kind] - KIND_PRIORITY[b.entry.kind] ||
a.entry.name.length - b.entry.name.length ||
a.entry.name.localeCompare(b.entry.name)
);
return scored.slice(0, limit).map((match) => match.entry);
}
@@ -0,0 +1,150 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { SearchComponent } from './search.component';
function starRecord(id: number, name: string): StarRecord {
return { id, name, x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G2V', colorIndex: 0.65 };
}
/** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */
const STARS: StarRecord[] = [
...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)),
starRecord(1, 'Proxima Centauri')
];
const IO: BodyRecord = {
id: 'io',
systemStarId: 0,
name: 'Io',
kind: 'moon',
parentBodyId: 'jupiter',
radiusKm: 1821,
orbit: {
semiMajorAxisAu: 0.002819,
eccentricity: 0.004,
inclinationDeg: 0,
longitudeOfAscendingNodeDeg: 0,
argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0,
epochJd: 2451545.0
}
};
const PROXIMA_B: ExoplanetRecord = {
id: 'Proxima Cen b',
hostStarId: 1,
hostStarName: 'Proxima Centauri',
name: 'Proxima Cen b',
orbit: { semiMajorAxisAu: 0.0485, eccentricity: 0.02 }
};
class FakeDataLoaderService {
loadStars(): Promise<StarField> {
return Promise.resolve({ stars: STARS, positions: new Float32Array(STARS.length * 3) });
}
loadBodies(): Promise<BodyRecord[]> {
return Promise.resolve([IO]);
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
return Promise.resolve([PROXIMA_B]);
}
}
describe('SearchComponent', () => {
let fixture: ComponentFixture<SearchComponent>;
let element: HTMLElement;
let navigationStore: NavigationStore;
let router: { navigate: ReturnType<typeof vi.fn> };
async function type(query: string): Promise<void> {
fixture.componentInstance.query.set(query);
await fixture.whenStable();
}
function resultNames(): string[] {
return [...element.querySelectorAll('[data-testid="search-results"] button')].map((button) =>
(button.querySelector('span')?.textContent ?? '').trim()
);
}
beforeEach(async () => {
router = { navigate: vi.fn().mockResolvedValue(true) };
await TestBed.configureTestingModule({
imports: [SearchComponent],
providers: [
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
{ provide: Router, useValue: router }
]
}).compileComponents();
fixture = TestBed.createComponent(SearchComponent);
navigationStore = TestBed.inject(NavigationStore);
await fixture.whenStable();
element = fixture.nativeElement as HTMLElement;
});
it('shows no results until the query is long enough', async () => {
await type('I');
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
it('finds an exact match that sits behind thousands of stars in the index', async () => {
// The regression this ranking exists for: the moon Io is indexed after every star, so the
// old first-8-substring-hits scan filled up on "Iot ..." names and never reached it.
await type('Io');
expect(resultNames()[0]).toBe('Io');
});
it('puts a host star ahead of its own planets', async () => {
await type('Proxima');
expect(resultNames()[0]).toBe('Proxima Centauri');
expect(resultNames()).toContain('Proxima Cen b');
});
it('shows nothing for a query that matches nothing', async () => {
await type('zzzzz');
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
it('selects a star and returns to the galaxy route', async () => {
await type('Proxima Centauri');
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
await fixture.whenStable();
expect(navigationStore.selectedStarId()).toBe(1);
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('navigates straight to a body detail route', async () => {
await type('Io');
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
await fixture.whenStable();
expect(router.navigate).toHaveBeenCalledWith(['/body', 'io']);
});
it('clears the query after a selection, so the list closes', async () => {
await type('Io');
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
await fixture.whenStable();
expect(fixture.componentInstance.query()).toBe('');
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
it('clears the query on demand', async () => {
await type('Io');
fixture.componentInstance.clear();
await fixture.whenStable();
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
});
+6 -25
View File
@@ -3,18 +3,7 @@ import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
type SearchResultKind = 'star' | 'body' | 'exoplanet';
interface SearchEntry {
kind: SearchResultKind;
name: string;
subtitle: string;
/** HYG star id, for `kind: 'star'` results. */
starId?: number;
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
bodyId?: string;
}
const MAX_RESULTS = 8; const MAX_RESULTS = 8;
const MIN_QUERY_LENGTH = 2; const MIN_QUERY_LENGTH = 2;
@@ -70,23 +59,15 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
}) })
export class SearchComponent { export class SearchComponent {
readonly query = signal(''); readonly query = signal('');
private readonly index = signal<SearchEntry[]>([]); /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
private readonly index = signal<IndexedSearchEntry[]>([]);
readonly results = computed(() => { readonly results = computed(() => {
const query = this.query().trim().toLowerCase(); const query = this.query().trim();
if (query.length < MIN_QUERY_LENGTH) { if (query.length < MIN_QUERY_LENGTH) {
return []; return [];
} }
const matches: SearchEntry[] = []; return rankSearchResults(this.index(), query, MAX_RESULTS);
for (const entry of this.index()) {
if (entry.name.toLowerCase().includes(query)) {
matches.push(entry);
if (matches.length >= MAX_RESULTS) {
break;
}
}
}
return matches;
}); });
constructor( constructor(
@@ -133,7 +114,7 @@ export class SearchComponent {
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id })) ...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
]; ];
this.index.set(entries); this.index.set(buildSearchIndex(entries));
} catch (error) { } catch (error) {
console.error('Failed to build the search index.', error); console.error('Failed to build the search index.', error);
} }