feat: split RU-ARK into mainland + New Earth + Severnaya Zemlya — idempotent stage, manifests 1,798 (v0.25.0, #31)

This commit is contained in:
2026-10-14 17:54:45 +00:00
parent c63f6acca1
commit f593af5ba5
10 changed files with 318 additions and 35 deletions
+4 -4
View File
@@ -21,7 +21,7 @@
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
* regions all named "Western") the 4-char codes are made distinct by
* re-deriving with a deterministic salt on collision, which guarantees the
* full key is unique across all 1,796 regions.
* full key is unique across all 1,798 regions.
*
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
@@ -107,8 +107,8 @@ const geoFile = path.join(root, 'public', 'regions.geo.json');
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
const features = Array.isArray(geo?.features) ? geo.features : [];
if (features.length !== 1796) {
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
if (features.length !== 1798) {
throw new Error(`expected 1,798 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
}
// Group features by their (computed) name-slug, preserving file order, so the
@@ -154,5 +154,5 @@ writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
const western = Object.entries(manifest)
.filter(([, k]) => k.startsWith('western-'))
.sort((a, b) => a[0] < b[0] ? -1 : 1);
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`);
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,798 expected)`);
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
+23 -1
View File
@@ -34,7 +34,7 @@ describe('region-adjacency.json', () => {
const adjacencyKeys = new Set(Object.keys(adjacency));
expect(adjacencyKeys).toEqual(manifestKeys);
expect(Object.keys(adjacency)).toHaveLength(keys.size);
expect(adjacencyKeys.size).toBe(1796);
expect(adjacencyKeys.size).toBe(1798);
});
it('every neighbour is itself a valid region key (referential integrity)', () => {
@@ -84,4 +84,26 @@ describe('region-adjacency.json', () => {
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
});
it("the #31 island splits: New Earth is isolated; Severnaya Zemlya touches only the Arkhangel's main ring", () => {
// v0.25 (#31) split RU-ARK into the mainland (arkhangel-sk-lhbk), the
// Franz Josef Land islets (new-earth-eikt) and the Severnaya Zemlya
// islets (severnaya-zemlya-awqp). New Earth sits ~100+ km from any land
// → a genuinely isolated island (empty list). Severnaya Zemlya's
// digitized islet rings share boundary vertices with the coarsened
// mainland ring (a pre-existing intra-feature contact, exposed by the
// split) → it touches exactly its former parent, with no overlap area.
expect(adjacency['new-earth-eikt']).toEqual([]);
expect(adjacency['severnaya-zemlya-awqp']).toEqual(['arkhangel-sk-lhbk']);
// the mainland keeps its six real neighbours plus the island contact
expect(adjacency['arkhangel-sk-lhbk']).toEqual([
'karelia-ohem',
'kirov-gfvm',
'komi-eoeh',
'murmansk-etas',
'nenets-qyga',
'severnaya-zemlya-awqp',
'vologda-ynvr',
]);
});
});
+15 -5
View File
@@ -33,13 +33,13 @@ describe('region-keys.json', () => {
const keys = Object.values(manifest);
const featureIds = (geo.features ?? []).map((f) => String(f.id));
it('has exactly 1,796 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1796);
it('has exactly 1,798 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1798);
});
it('has 1,796 unique keys (no collisions)', () => {
it('has 1,798 unique keys (no collisions)', () => {
expect(new Set(keys).size).toBe(keys.length);
expect(new Set(keys).size).toBe(1796);
expect(new Set(keys).size).toBe(1798);
});
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
@@ -50,11 +50,21 @@ describe('region-keys.json', () => {
}
});
it("keeps RU-ARK's key stable across the #31 split and mints the two island keys", () => {
// v0.25 (#31) split RU-ARK into main + RU-ARK-NE + RU-ARK-SZ. The MAIN
// feature keeps its id (and therefore its key — FNV-1a of the id), which
// is what keeps existing Nation.territory / SQLite ownership valid; the
// two new features get fresh keys derived the same way from their ids.
expect(manifest['RU-ARK']).toBe('arkhangel-sk-lhbk'); // unchanged since v0.19
expect(manifest['RU-ARK-NE']).toBe('new-earth-eikt');
expect(manifest['RU-ARK-SZ']).toBe('severnaya-zemlya-awqp');
});
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
// The manifest is looked up by feature id, so its key set must line up
// 1:1 with the geo file (catches drift if the geo is regenerated without
// rebuilding the keys).
expect(featureIds).toHaveLength(1796);
expect(featureIds).toHaveLength(1798);
const manifestIds = Object.keys(manifest);
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
});
+54 -12
View File
@@ -530,16 +530,20 @@ describe('regions.geo.json', () => {
);
});
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
it('is at the v0.25 fixed point: 1,798 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
// v0.17 (scripts/fill-gaps.mjs) APPENDED cleaned gap pieces to the v0.16
// fixed point: it never moves an existing border, merges a part, or
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
// keeps the single KZ-KZY hole. The region count (1,796), the
// per-feature ids/names/parents and their order are unchanged; only the
// part/ring/vertex totals and the hole's part structure moved. Pinning
// the signature guards against a non-idempotent or destructive re-run.
// The fill is byte-deterministic per input and idempotent (a second run
// on the filled file changes nothing).
// keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ) — a pure
// re-grouping: no part/ring/vertex moves, so only the region count
// changed (1,796 → 1,798); the part/ring/vertex totals, the per-feature
// ids/names/parents, and the feature order (the two new features sit
// immediately after RU-ARK) are as pinned below. Pinning the signature
// guards against a non-idempotent or destructive re-run; both stages are
// byte-deterministic per input and idempotent (a second run changes
// nothing).
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
let verts = 0,
rings = 0,
@@ -556,15 +560,53 @@ describe('regions.geo.json', () => {
for (const ring of poly) verts += ring.length;
}
}
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
expect(regions.features.length, 'region count').toBe(1798); // v0.17: 1,796 → +2 by the #31 split
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces; unchanged by the split
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
expect(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175; unchanged by the split
console.log(
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
`\n v0.25 fixed point: ${regions.features.length} regions (1,796 + the #31 split's two islands), ` +
`${parts} parts, ${rings} rings, ${holes} hole (KZ-KZY), ${verts} vertices — v0.17 topology preserved`,
);
});
it('has the three Arkhangel\'sk features split per #31 (RU-ARK main + New Earth + Severnaya Zemlya)', () => {
// v0.25 (#31): the ~411,500 km² RU-ARK mega-region (a 78-part MultiPolygon
// mixing mainland and far-north islands) was repartitioned by part
// centroid into the mainland (RU-ARK, keeps its id/name/key), the Franz
// Josef Land islets (RU-ARK-NE "New Earth", centroid lat ≥ 79.5) and the
// Severnaya Zemlya islets (RU-ARK-SZ, centroid 76–79.5° N, lon ≥ 55°E).
// Pure re-grouping: all three keep parent RUS and all 78 original parts.
const byId = new Map(regions.features.map((f) => [f.id, f]));
const ark = byId.get('RU-ARK');
const ne = byId.get('RU-ARK-NE');
const sz = byId.get('RU-ARK-SZ');
expect(ark, 'RU-ARK (the main feature) exists').toBeDefined();
expect(ne, 'RU-ARK-NE (New Earth) exists').toBeDefined();
expect(sz, 'RU-ARK-SZ (Severnaya Zemlya) exists').toBeDefined();
for (const f of [ark!, ne!, sz!]) {
expect(f.properties.parent, `${f.id} parent`).toBe('RUS');
expect(f.geometry.type, `${f.id} geometry`).toBe('MultiPolygon');
}
expect(ark!.properties.name, 'RU-ARK keeps its name').toBe("Arkhangel'sk");
expect(ne!.properties.name, 'RU-ARK-NE name').toBe('New Earth');
expect(sz!.properties.name, 'RU-ARK-SZ name').toBe('Severnaya Zemlya');
// the 78 parts of the pre-split feature are all accounted for, verbatim
const countParts = (f: (typeof regions.features)[number]) =>
f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.length : 1;
expect(
countParts(ark!) + countParts(ne!) + countParts(sz!),
'total parts across the three features',
).toBe(78);
// stable file order: the two new features sit immediately after RU-ARK
const idx = regions.features.findIndex((f) => f.id === 'RU-ARK');
expect(regions.features[idx + 1]?.id).toBe('RU-ARK-NE');
expect(regions.features[idx + 2]?.id).toBe('RU-ARK-SZ');
console.log(
`\n #31 split: RU-ARK ${countParts(ark!)} parts (mainland), RU-ARK-NE ${countParts(ne!)} parts (New Earth), ` +
`RU-ARK-SZ ${countParts(sz!)} parts (Severnaya Zemlya)`,
);
});
});
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* scripts/split-arkhangel.mjs — split the RU-ARK ("Arkhangel'sk") mega-region
* into main + two detached island archipelagos (issue #31).
*
* `RU-ARK` is a 78-part MultiPolygon of ~411,500 km²: the Arkhangel'sk
* mainland plus Arctic-coast mainland parts, tiny islets, AND two far-north
* island groups that belong nowhere near the mainland — the Franz Josef Land
* islets (New Earth, centroid lat ≥ 79.5) and the Severnaya Zemlya islets
* (centroid lat 76–79.5, lon ≥ 55). This stage repartitions the existing
* polygon parts between three features so each island group is its own
* addressable sub-region:
*
* RU-ARK (keeps its id and name) — every part whose centroid has
* lat < 76 (the mainland, Arctic-coast
* mainland parts, islets, one south dot)
* RU-ARK-NE "New Earth" — every part whose centroid has
* lat ≥ 79.5 (the 42 Franz Josef Land
* islets, ~15,700 km²)
* RU-ARK-SZ "Severnaya Zemlya" — every part whose centroid has
* 76 ≤ lat < 79.5 AND lon ≥ 55 (the 3
* Severnaya Zemlya islets, ~1,200 km²)
*
* (Parts in the 76–79.5° band with lon < 55 would stay in RU-ARK; none exist
* in the current dataset — the split is verified clean before it is written.)
*
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
* moved verbatim (same coordinates, same ring order) from the one feature to
* the other. The union of the three output geometries is byte-identical to
* the input feature's parts, so no border moves and no new overlap is
* possible. RU-ARK keeps its stable feature id, so its region key
* (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
* new ids (`RU-ARK-NE`, `RU-ARK-SZ`) get their own keys derived by the same
* FNV-1a rule in `scripts/build-region-keys.mjs` (run it after this stage).
*
* The new features are inserted immediately after RU-ARK (stable file order,
* deterministic bytes). Each new feature's `properties` copies RU-ARK's
* (only `name` and `parent` exist in this dataset) with `name` overridden;
* `parent` stays RUS. Each feature's geometry is a MultiPolygon of its
* assigned parts, or a single Polygon if it ends up with one part.
*
* Pipeline position: between `fill-gaps` and `build-region-keys` (the
* manifests must be rebuilt after any geo change):
*
* generate → coarsen → cleanup → smooth → fill-gaps → **split-arkhangel**
* → build-region-keys → build-region-adjacency
*
* Idempotent: when RU-ARK-NE and RU-ARK-SZ already exist the file is left
* untouched (no write at all), so a full re-run of the pipeline reproduces
* byte-identical output.
*
* Usage: node scripts/split-arkhangel.mjs [inFile [outFile]]
* (or `npm run split:regions`)
* inFile default: public/regions.geo.json
* outFile default: inFile (edit in place)
*
* `@turf/centroid` + `@turf/helpers` are used only to classify each part by
* its centroid — the same measurement the split thresholds are defined in.
* (Turf is allowed in .mjs scripts only; never imported by any .ts file.)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import centroid from '@turf/centroid';
import area from '@turf/area';
import { polygon } from '@turf/helpers';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
const outFile = process.argv[3] ?? inFile;
// The split thresholds (degrees), per issue #31.
const NEW_EARTH_MIN_LAT = 79.5; // centroid lat ≥ this → New Earth (Franz Josef Land)
const SZ_BAND_MIN_LAT = 76; // centroid lat ≥ this (and < NEW_EARTH_MIN_LAT)…
const SZ_MIN_LON = 55; // …and centroid lon ≥ this → Severnaya Zemlya
// (anything else — including the 76–79.5° band west of 55°E, of which the
// current dataset has none — stays in the main Arkhangel'sk feature)
/** A GeoJSON polygon part as a turf Feature (for centroid/area only). */
const partFeature = (part) => polygon(part);
/**
* A part's centroid [lon, lat]. Strict by design: a centroid that cannot be
* computed is a data error, not something to silently route to the mainland.
*/
function centroidOf(part) {
try {
return centroid(partFeature(part)).geometry.coordinates;
} catch (err) {
throw new Error(`cannot compute centroid for a RU-ARK part: ${err.message}`);
}
}
/** Classify a centroid [lon, lat] into one of the three output buckets. */
function bucketOf([lon, lat]) {
if (lat >= NEW_EARTH_MIN_LAT) return 'ne';
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon >= SZ_MIN_LON) return 'sz';
return 'main';
}
// --- load --------------------------------------------------------------------
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
throw new Error(`${inFile} is not a FeatureCollection`);
}
const features = geo.features;
// --- idempotency: already split? ----------------------------------------------
const ids = new Set(features.map((f) => String(f.id ?? '')));
if (ids.has('RU-ARK-NE') && ids.has('RU-ARK-SZ')) {
console.log(
`[split-arkhangel] RU-ARK-NE and RU-ARK-SZ already present — no-op (idempotent), ${inFile} left untouched`,
);
process.exit(0);
}
const idx = features.findIndex((f) => f.id === 'RU-ARK');
if (idx < 0) {
throw new Error('RU-ARK not found in ' + inFile);
}
const ark = features[idx];
if (ark.geometry?.type !== 'MultiPolygon') {
throw new Error(
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the split expects the pre-#31 mega-region)`,
);
}
const parts = ark.geometry.coordinates;
// --- partition (stable: original part order is kept inside each bucket) -------
const buckets = { main: [], ne: [], sz: [] };
let bandWest = 0; // 76–79.5°-band parts west of 55°E (stay in main; 0 in the current data)
for (const part of parts) {
const [lon, lat] = centroidOf(part);
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon < SZ_MIN_LON) bandWest += 1;
buckets[bucketOf([lon, lat])].push(part);
}
// --- rebuild geometries (parts verbatim; MultiPolygon, or Polygon if 1 part) --
const geom = (ps) =>
ps.length > 1
? { type: 'MultiPolygon', coordinates: ps }
: { type: 'Polygon', coordinates: ps[0] };
ark.geometry = geom(buckets.main);
const mkFeature = (id, name, ps) => ({
type: 'Feature',
id,
// copy every property RU-ARK has (currently just { name, parent }), overriding name
properties: { ...ark.properties, name },
geometry: geom(ps),
});
const ne = mkFeature('RU-ARK-NE', 'New Earth', buckets.ne);
const sz = mkFeature('RU-ARK-SZ', 'Severnaya Zemlya', buckets.sz);
// --- report -------------------------------------------------------------------
const km2 = (ps) => ps.reduce((s, p) => s + area(partFeature(p)) * 1e-6, 0);
console.log(`[split-arkhangel] input: ${inFile}`);
console.log(
` RU-ARK (main) ${buckets.main.length} parts (of ${parts.length}), ≈ ${Math.round(km2(buckets.main)).toLocaleString('en-US')} km²` +
(bandWest ? ` — incl. ${bandWest} part(s) in the 76–79.5° band west of 55°E (kept in main)` : ''),
);
console.log(
` RU-ARK-NE (New Earth) ${buckets.ne.length} parts, ≈ ${Math.round(km2(buckets.ne)).toLocaleString('en-US')} km²`,
);
console.log(
` RU-ARK-SZ (Severnaya Zemlya) ${buckets.sz.length} parts, ≈ ${Math.round(km2(buckets.sz)).toLocaleString('en-US')} km²`,
);
if (buckets.ne.length === 0 || buckets.sz.length === 0) {
throw new Error(
`threshold mis-split: New Earth=${buckets.ne.length} parts, Severnaya Zemlya=${buckets.sz.length} parts — check the centroids before shipping`,
);
}
// --- insert at a stable position (immediately after RU-ARK) and write ---------
features.splice(idx + 1, 0, ne, sz);
writeFileSync(outFile, JSON.stringify(geo));
console.log(`[split-arkhangel] wrote ${outFile} — features ${features.length - 2} → ${features.length}`);