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
+133
View File
@@ -0,0 +1,133 @@
/**
* scripts/build-region-adjacency.mjs — build the precomputed region-adjacency
* manifest: `public/region-adjacency.json` = `{ "<regionKey>": ["<neighbourKey>", ...], ... }`.
*
* For every sub-region (region key, see `public/region-keys.json`) this records
* which *other* sub-regions it **borders** — i.e. shares an edge or a point with.
* The result is the data behind the model's `getNeighbours` tool (P2 of #16):
* given a set of region keys, the engine returns the union of their adjacency
* entries (excluding the queried keys), so the LLM can plan *frontier-biased,
* contiguous* territory transfers (a nation realistically gains/loses a region
* it already abuts) instead of whole countries.
*
* Method — bounding-box-pruned polygon intersection (turf is allowed in .mjs
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
* v0.17 note). For each unordered pair of regions (i < j) we first do a cheap
* bbox-proximity reject with a small gap tolerance (adjacent regions' bboxes
* touch, so their gap is ≈0), and only for the survivors do we run
* `@turf/boolean-intersects`, which returns true exactly when the two
* (Multi)Polygons share at least one point (edge or corner touch, or overlap).
* A hit adds each key to the other's set — adjacency is recorded **symmetrically**
* and **deduplicated** (a neighbour appears once even if the pair shares several
* disjoint border segments).
*
* Output contract (asserted by scripts/region-adjacency.test.ts):
* - one entry for **every** region key (including regions with no bordering
* neighbours, e.g. isolated islands — their list is simply empty);
* - adjacency is symmetric (A ∈ B's list ⟺ B ∈ A's list);
* - no key lists itself.
*
* Deterministic + idempotent: the input files are fixed, the intersection test
* is a pure geometric computation, and the output is written with keys and
* neighbour lists both sorted — so a re-run rewrites a byte-identical file.
*
* Usage: `node scripts/build-region-adjacency.mjs` (or `npm run build:region-adjacency`).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import bbox from '@turf/bbox';
import booleanIntersects from '@turf/boolean-intersects';
import { feature } from '@turf/helpers';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
// "Close enough" bbox gap (degrees) for the cheap reject. Adjacent regions'
// bboxes touch (gap ≈ 0); a small tolerance absorbs 4-dp coordinate rounding
// and digitization fuzz. It only prunes work — it never affects correctness,
// because `booleanIntersects` is the final arbiter (a pair is a neighbour only
// if the polygons actually share a point).
const GAP_DEG = 0.05; // ≈ 5.5 km
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
// Region records in stable file order, keyed by their region key.
const regions = [];
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = idToKey[id];
if (!key) continue; // a geo feature with no manifest entry is skipped
regions.push({ key, feature: feature(f.geometry), bbox: bbox(f.geometry) });
}
if (regions.length === 0) {
throw new Error('no regions with valid region keys — check regions.geo.json / region-keys.json');
}
// bboxes are flat [minX, minY, maxX, maxY] (turf's BBox type).
function bboxesClose(a, b) {
return (
a[0] <= b[2] + GAP_DEG && // a.w <= b.e + gap
b[0] <= a[2] + GAP_DEG && // b.w <= a.e + gap
a[1] <= b[3] + GAP_DEG && // a.s <= b.n + gap
b[1] <= a[3] + GAP_DEG // b.s <= a.n + gap
);
}
// key -> set of neighbouring keys (deduped; a pair sharing several segments is
// recorded once).
const adj = new Map();
for (const r of regions) adj.set(r.key, new Set());
let pairChecks = 0;
let intersections = 0;
for (let i = 0; i < regions.length; i++) {
for (let j = i + 1; j < regions.length; j++) {
if (!bboxesClose(regions[i].bbox, regions[j].bbox)) continue; // cheap reject
pairChecks++;
let touches = false;
try {
touches = booleanIntersects(regions[i].feature, regions[j].feature);
} catch {
touches = false; // a degenerate geometry pair is treated as non-adjacent
}
if (touches) {
intersections++;
adj.get(regions[i].key).add(regions[j].key);
adj.get(regions[j].key).add(regions[i].key);
}
}
}
// --- write idempotently: keys sorted, neighbour lists sorted ------------------
// Every region key gets an entry (an isolated island's list is simply empty).
const manifest = {};
for (const key of [...adj.keys()].sort()) {
manifest[key] = [...adj.get(key)].sort();
}
// Self-reference guard (can't happen — we only ever add the *other* region — but
// assert it so a future regression is loud rather than silent).
for (const [k, list] of Object.entries(manifest)) {
if (list.includes(k)) throw new Error(`region ${k} lists itself as a neighbour`);
}
writeFileSync(path.join(root, 'public', 'region-adjacency.json'), JSON.stringify(manifest, null, 2) + '\n');
// --- summary ------------------------------------------------------------------
const totalEntries = Object.values(manifest).reduce((n, l) => n + l.length, 0);
const withNeighbours = Object.values(manifest).filter((l) => l.length > 0).length;
const maxDegree = Math.max(0, ...Object.values(manifest).map((l) => l.length));
console.log(
`[build-region-adjacency] wrote public/region-adjacency.json — ${Object.keys(manifest).length} region keys, ` +
`${totalEntries / 2} border pairs (${totalEntries} directed entries), max degree ${maxDegree}, ` +
`${withNeighbours} with ≥1 neighbour (${pairChecks} bbox-close pairs tested, ${intersections} intersect)`,
);
// A couple of human-checkable examples (highest-degree regions, stable order).
const byDegree = Object.entries(manifest).sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
for (const [k, list] of byDegree.slice(0, 3)) {
console.log(` e.g. ${k} (deg ${list.length}) → ${list.slice(0, 4).join(', ')}${list.length > 4 ? ', …' : ''}`);
}