Merge pull request #17 from avalon-vanguard/star-map/guard/merge-quality
Fail the ETL on a merge that keeps the same star twice Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -41,7 +41,8 @@ jobs:
|
|||||||
|
|
||||||
# The ETL's cache directory is gitignored and this is a fresh runner, so every source is
|
# The ETL's cache directory is gitignored and this is a fresh runner, so every source is
|
||||||
# fetched live (~50-100 MB). A failed fetch fails the run by design — no refresh is
|
# fetched live (~50-100 MB). A failed fetch fails the run by design — no refresh is
|
||||||
# better than a partial one — except Gaia, which the ETL itself treats as best-effort.
|
# better than a partial one. That includes Gaia: the ETL skips it when unreachable, and
|
||||||
|
# the merge gate in build.ts then refuses a catalogue it contributed nothing to.
|
||||||
- name: Rebuild the datasets
|
- name: Rebuild the datasets
|
||||||
run: npm run etl
|
run: npm run etl
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,77 @@ function validateStars(stars: StarRecord[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a good merge looks like, in two numbers the unit suite cannot see.
|
||||||
|
*
|
||||||
|
* The catalogues are regenerated by a scheduled job that pushes straight to `main` once the unit
|
||||||
|
* tests and a production build pass — and both passed, for weeks, on a catalogue carrying 23 000
|
||||||
|
* stars twice: the suite tests code against fixtures, and no fixture is 400 000 real stars. The
|
||||||
|
* two ways the merge has actually failed both show up here.
|
||||||
|
*
|
||||||
|
* A star kept twice leaves its two entries near each other on the sky, from *different* sources —
|
||||||
|
* one catalogue does not list a star twice. Under an arcsecond that is never two stars at this
|
||||||
|
* depth, so every such pair is a miss. Nineteen survive today, all of them a second HYG row
|
||||||
|
* wanting a Gaia entry that already absorbed one (Gliese lists some doubles twice); the merge
|
||||||
|
* that trusted a Hipparcos parallax over direction left 1 112.
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
const MAX_UNMERGED_TWINS = 100;
|
||||||
|
const MAX_HYG_SURVIVORS = 15_000;
|
||||||
|
const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180);
|
||||||
|
|
||||||
|
function validateMerge(stars: StarRecord[]): void {
|
||||||
|
// Checked first and on its own: an unreachable Gaia is skipped rather than thrown, and would
|
||||||
|
// otherwise surface below as "68 000 HYG stars found no counterpart" — true, and no help.
|
||||||
|
assertCondition(
|
||||||
|
stars.some((star) => star.source === 'gaia'),
|
||||||
|
'Gaia DR3 contributed no stars — the archive was unreachable or returned nothing, and a catalogue without it is not one to publish.'
|
||||||
|
);
|
||||||
|
const survivors = stars.filter((star) => star.source === 'hyg').length;
|
||||||
|
assertCondition(
|
||||||
|
survivors <= MAX_HYG_SURVIVORS,
|
||||||
|
`${survivors} HYG stars found no Gaia counterpart (at most ${MAX_HYG_SURVIVORS} expected) — the two catalogues are not being matched.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sorted by declination, so each star is only compared against the handful sharing its
|
||||||
|
// parallel — an arcsecond of declination holds one or two of 400 000 stars.
|
||||||
|
const byDec = stars
|
||||||
|
.map((star) => {
|
||||||
|
const distance = Math.hypot(star.x, star.y, star.z);
|
||||||
|
return { star, distance, dec: distance === 0 ? 0 : Math.asin(Math.max(-1, Math.min(1, star.z / distance))) };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.distance > 0)
|
||||||
|
.sort((a, b) => a.dec - b.dec);
|
||||||
|
|
||||||
|
const cosTolerance = Math.cos(TWIN_TOLERANCE_RAD);
|
||||||
|
let twins = 0;
|
||||||
|
let example = '';
|
||||||
|
for (let i = 0; i < byDec.length; i++) {
|
||||||
|
const a = byDec[i];
|
||||||
|
for (let j = i + 1; j < byDec.length && byDec[j].dec - a.dec <= TWIN_TOLERANCE_RAD; j++) {
|
||||||
|
const b = byDec[j];
|
||||||
|
if (a.star.source === b.star.source) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cosine = (a.star.x * b.star.x + a.star.y * b.star.y + a.star.z * b.star.z) / (a.distance * b.distance);
|
||||||
|
if (cosine >= cosTolerance) {
|
||||||
|
twins++;
|
||||||
|
example ||= `${a.star.name} (${a.star.source}) and ${b.star.name} (${b.star.source})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertCondition(
|
||||||
|
twins <= MAX_UNMERGED_TWINS,
|
||||||
|
`${twins} stars from different catalogues sit within an arcsecond of each other (at most ${MAX_UNMERGED_TWINS} expected), starting with ${example} — the merge is keeping the same star twice.`
|
||||||
|
);
|
||||||
|
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond.`);
|
||||||
|
}
|
||||||
|
|
||||||
function validateBodies(bodies: BodyRecord[]): void {
|
function validateBodies(bodies: BodyRecord[]): void {
|
||||||
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
|
||||||
|
|
||||||
@@ -162,6 +233,7 @@ async function build(): Promise<void> {
|
|||||||
|
|
||||||
console.log('Validating output...');
|
console.log('Validating output...');
|
||||||
validateStars(stars);
|
validateStars(stars);
|
||||||
|
validateMerge(stars);
|
||||||
validateBodies(bodies);
|
validateBodies(bodies);
|
||||||
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
||||||
validateDeepSky(deepSky);
|
validateDeepSky(deepSky);
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
|||||||
/**
|
/**
|
||||||
* Unions HYG with every other positional source that is wired in and reachable.
|
* Unions HYG with every other positional source that is wired in and reachable.
|
||||||
*
|
*
|
||||||
* A source that cannot be reached is reported and skipped rather than failing the run. That is
|
* A source that cannot be reached is reported and skipped here rather than thrown, so a run still
|
||||||
* not defensive padding: the archives this would draw on are frequently unavailable, and a build
|
* gets as far as validation and says what it has. Whether that may be published is decided
|
||||||
* that produces a smaller catalogue is far better than one that produces none.
|
* there: `validateMerge` in build.ts refuses a catalogue Gaia contributed nothing to.
|
||||||
*/
|
*/
|
||||||
async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord[]> {
|
async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord[]> {
|
||||||
const others = positionalSources().filter((source) => source.id !== 'hyg');
|
const others = positionalSources().filter((source) => source.id !== 'hyg');
|
||||||
|
|||||||
Reference in New Issue
Block a user