Build region-adjacency manifest (Stage 1 of #16 P2)

Generate public/region-adjacency.json: regionKey -> neighbourKeys, built
from regions.geo.json (bbox proximity reject + @turf/boolean-intersects
arbitration; symmetric, deduped, no self-refs). Deterministic + idempotent.

This is the data backing the LLM's getNeighbours tool (Stage 2) which lets
it move contiguous frontier regions instead of whole countries.
This commit is contained in:
2026-10-14 10:05:06 +00:00
parent 6f14a4e4c0
commit f53f9072f4
4 changed files with 12686 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* scripts/region-adjacency.test.ts — validation for the committed
* `public/region-adjacency.json` (built by `scripts/build-region-adjacency.mjs`).
*
* The manifest maps each region key to the list of region keys it **borders**
* (shares an edge/point with). It is the data behind the model's `getNeighbours`
* tool (v0.21, #16 P2), so it is a load-bearing data artifact — this test pins
* its contract.
*
* Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse`
* and never imports `@turf`/geojson types (importing one pulls `@types/geojson`
* into the tsc program and breaks `WorldMap.tsx`; see the v0.17 changelog note).
* Mirrors the style of `scripts/region-keys.test.ts`.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function readJson(rel: string): Record<string, unknown> {
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
}
describe('region-adjacency.json', () => {
const adjacency = readJson('public/region-adjacency.json') as Record<string, string[]>;
const manifest = readJson('public/region-keys.json') as Record<string, string>;
const keys = new Set(Object.values(manifest));
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
const manifestKeys = new Set(Object.values(manifest));
const adjacencyKeys = new Set(Object.keys(adjacency));
expect(adjacencyKeys).toEqual(manifestKeys);
expect(Object.keys(adjacency)).toHaveLength(keys.size);
expect(adjacencyKeys.size).toBe(1796);
});
it('every neighbour is itself a valid region key (referential integrity)', () => {
for (const [key, neighbours] of Object.entries(adjacency)) {
for (const n of neighbours) {
expect(keys.has(n), `neighbour ${n} of ${key} is not a valid region key`).toBe(true);
}
}
});
it('no region lists itself as a neighbour', () => {
for (const [key, neighbours] of Object.entries(adjacency)) {
expect(neighbours.includes(key), `${key} lists itself`).toBe(false);
}
});
it('adjacency is symmetric (A ∈ B ⟺ B ∈ A)', () => {
let checked = 0;
for (const [a, aList] of Object.entries(adjacency)) {
const aSet = new Set(aList);
for (const b of aList) {
const bList = adjacency[b];
expect(Array.isArray(bList), `${b} has no adjacency entry`).toBe(true);
// A lists B, so B must list A.
expect(bList!.includes(a), `${a} → ${b} but ${b} does not list ${a}`).toBe(true);
checked++;
}
// And the converse: every neighbour B of A must be a key (implied above) —
// also assert no *missing* reverse entry by checking the full set equality.
const bSet = new Set(aSet);
for (const b of aList) bSet.add(b);
// Symmetry means a's set equals the set of {b : a ∈ b}.
const reverse = Object.entries(adjacency)
.filter(([, list]) => list.includes(a))
.map(([k]) => k);
expect(new Set(reverse).size, `asymmetric degree for ${a}`).toBe(aList.length);
expect(new Set(aList).size).toBe(aSet.size); // no duplicate neighbours
}
expect(checked).toBeGreaterThan(0);
});
it('spot-checks known real bordering pairs', () => {
// Brazil ↔ Peru (Acre borders Loreto) — a real, unambiguous land border.
expect(adjacency['acre-ekzw']?.includes('loreto-ipkx')).toBe(true);
expect(adjacency['loreto-ipkx']?.includes('acre-ekzw')).toBe(true);
// France ↔ Germany (Moselle borders Baden-Württemberg) — another real border.
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
});
});