From 292b07aa911046304b589f94e05e758388ee60b3 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 23:04:43 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20round=2023=20(v5.0-alpha):=20the=20s?= =?UTF-8?q?pacing=20warn=20=E2=80=94=20D's=20thin-tail=20finding=20as=20la?= =?UTF-8?q?w=20(ledger=20#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateTownCache now warns when medianShopSpacing(cache) > MAX_MEDIAN_SPACING_M (150 m). Metric + threshold exported from the barrel so no lane re-derives them. WARN, NEVER ERROR: a flagged cache still boots a valid district (toowoomba shipped green through the entire v4.0 pack) — E curates, A flags. selfcheck ALL GREEN 161,372/161,372 (+72); synthetic 0x3fa36874 frozen; NO golden moved (only E's toowoomba re-bbox may move one this round, per the round's law). THE THRESHOLD IS MEASURED, NOT PICKED. The brief offered darwin 27 m / toowoomba 388 m as "plenty of daylight for a line" — but that's 2 points. Measuring all 23 towns shows a near-continuum, and the line is FORCED into (119.3, 254.9]: braddon at 119.3 m is ALIVE by D's own hub test (7 shops within 160 m, identical to darwin's 7), ballarat at 254.9 m is not. 150 m sits in the pack's widest gap (x2.14), with 1.26x margin below and 1.70x above. Flags 2 of 23. TWO TOWNS FLAG, NOT ONE. The brief names only toowoomba (D spot-checked the two 12-shop towns). The measurement adds BALLARAT at 255 m — nobody has looked at it, and it's independently one of the three towns my R22 graded cluster fallback fired on (no venue candidate at the >=3-shops/160 m floor). Two signals agree. Filed for E as a curation call, counted either way. THE BRIEF'S ANCHORS WERE IN THE WRONG SPACE, AND IT MATTERS. D's 27 m is PLAN space (post-lift metres); validateTownCache is CACHE space (raw lat/lon — the only thing that exists at ingest, and the layer where a bad bbox must be caught). Darwin is 68.3 m in cache space, not 27. Interpolating a threshold between D's figures would have silently mixed spaces. I verified the hypothesis before trusting it: computing the metric plan-side reproduces D's numbers exactly (darwin 27.2 vs 27, toowoomba 386.8 vs 388, hubs 7 and 3). Cache space is correct HERE and provably safe: the lift marches shops at a regularised cadence, so it COMPRESSES dense towns (darwin plan/cache 0.40) but not sparse ones (toowoomba 0.98, ballarat 1.01) -- the error lives at the dense end, the warn only fires at the sparse end. Verified all 23 towns in both spaces: exactly ONE disagrees, newtown_godverse (94.4 cache passes / 158.3 plan would warn). Filed for G/Fable, NOT acted on: no cache-space line catches it without false-positiving braddon, and godverse is a census subset (18 thriftgod shops over the footprint that carries 72 OSM shops) so its sparsity is coverage, not a bad bbox — re-bboxing, the warn's only remedy, cannot fix it. Worth knowing since R23 stocks the real crate in that town and the beta asks "fitzroy_godverse if the census is dense there". Also fixed en route: the R17 silent-fallback trap bit again in my own scratch harness — an unregistered town key resolves to the melbourne fixture rather than erroring, so five towns silently measured identical. Registered the caches; noting it because it has now cost two rounds. Coverage: sparse cache warns + still boots (structural + district suites on a flagged town), compact cache does NOT warn (no false positive), metric edge cases (< 2 shops -> null, coincident -> 0, no throw on empty), and an exact-value check (a 100 m pair measures 100 m, projection sane). Cost: ~1 ms/town at registration (25.6 ms for all 23 incl. fitzroy's 160 shops) vs a <100 ms plan budget. E -> use the warn as your acceptance test for #4: re-bbox, re-run, warn goes silent = the town has a high street. If your fix moves toowoomba's or ballarat's cache, ping me — goldens are mine to re-pin, and only what you move. Co-Authored-By: Claude Opus 4.8 --- docs/A-progress.md | 34 ++++++++++++++++++++++--- docs/CITY_SPEC.md | 26 +++++++++++++++++++ docs/LANES/LANE_A_NOTES.md | 50 +++++++++++++++++++++++++++++++++++++ web/js/citygen/index.js | 3 ++- web/js/citygen/plan_osm.js | 47 ++++++++++++++++++++++++++++++++++ web/js/citygen/selfcheck.js | 37 ++++++++++++++++++++++++++- 6 files changed, 191 insertions(+), 6 deletions(-) diff --git a/docs/A-progress.md b/docs/A-progress.md index 3299eb4..3c5ac9e 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,11 +1,37 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (161,300/161,300); rounds 2–22 closed** (see below). -R22 (v4.0 epoch close): the town pack absorbed — **23 towns, 1,210 shops, 36 goldens pinned in one commit**; -every town 1 connected component + 3 venues; drops 6.4%, counted by cause. classic `0x3fa36874` / -gig `0xb1d48ea1` / marched fixtures frozen.* +*Status: **all deliverables landed; self-check all-green (161,372/161,372); rounds 2–23 closed** (see below). +R23 (v5.0-alpha): D's thin-tail finding is law — `validateTownCache` warns above **150 m median shop +spacing**; flags 2 of 23 towns (toowoomba 396 m, **ballarat 255 m — beyond the brief**). Warn, never error. +R22: the pack absorbed — 23 towns, 36 goldens in one commit. classic `0x3fa36874` / gig `0xb1d48ea1` frozen.* *Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* +## Round 23 (2026-07-16) — v5.0-alpha: the spacing warn (ledger #5) + +D's R22 finding made law: `MIN_TOWN_SHOPS` counts shops, but what makes a town read alive is **clustering**. +`validateTownCache` now warns when `medianShopSpacing(cache) > MAX_MEDIAN_SPACING_M` (**150 m**) — the metric +and threshold both exported from the barrel, so no lane re-derives them. **Warn, never error**: a flagged +cache still boots a valid district (toowoomba shipped green through the whole v4.0 pack), E curates, the +validator flags. selfcheck **ALL GREEN 161,372/161,372** (+72), synthetic `0x3fa36874` frozen, no golden moved. + +- **The threshold is measured, not picked.** Fable's brief offered darwin 27 m / toowoomba 388 m as "plenty + of daylight for a line". Measuring all 23 towns showed the pack is a near-continuum, and the line is + **forced** into `(119.3, 254.9]` — braddon (119.3 m) is alive by D's own hub test (7 shops within 160 m, + identical to darwin's 7), ballarat (254.9 m) is not. 150 m sits in the pack's widest gap (**×2.14**). +- **Two towns flag, not one.** The brief names toowoomba; the measurement adds **ballarat (255 m)**, which + nobody had looked at — and which is independently one of the three towns my R22 cluster fallback fired on. + Filed for E as a curation call, counted either way. +- **The brief's anchors were in the wrong space, and it matters.** D's 27 m is **plan** space (post-lift); + `validateTownCache` is **cache** space (raw lat/lon — the only thing it has at ingest). Darwin is 68.3 m + in cache space, not 27. Coding a threshold interpolated between D's figures would have mixed spaces + silently. Cache space is right *here*, and provably safe: the lift compresses dense towns (darwin + plan/cache **0.40**) but not sparse ones (toowoomba **0.98**, ballarat **1.01**), so the error lives at the + dense end and the warn only fires at the sparse end. Verified all 23 in both spaces: **one town disagrees** + (`newtown_godverse`, 94.4 cache → 158.3 plan) — filed for G/Fable, not acted on, since no cache-space line + catches it without false-positiving braddon, and its sparsity is census *coverage*, which re-bboxing can't fix. +- Cost: ~1 ms/town at registration (25.6 ms for all 23 including fitzroy's 160 shops), against a <100 ms + plan budget. Pending: re-pin whatever E's toowoomba re-bbox moves — **only** that. + ## Round 22 (2026-07-16) — v4.0 THE EPOCH CLOSE: the pack absorbed + 36 goldens pinned (ledger #2) E's 17 new towns + Lane G's `newtown_godverse` fed through the lift and pinned in ONE golden commit diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 0825011..57490dc 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -394,6 +394,32 @@ it identically to the fixtures. **Hard requirements:** finite `center.{lat,lon}` blank name → type label, unknown `type` → `opshop`, dup ids, and **span > 5 km → "likely more than one town"** (a cache is ONE compact town — an over-broad bbox marches into an unusable multi-km strip). +**The spacing floor (v5.0-alpha, ROUND23 ledger #5 — D's R22 finding as law).** `MIN_TOWN_SHOPS` counts +shops; what makes a town **read alive** is *clustering*. D measured both 12-shop towns in the pack and they +disagree — which is the finding: **darwin ALIVE** (one strip; 1,455 patronage checks → 18 visits) vs +**toowoomba DEAD** (scattered over a 4.6 km road network; 1,417 checks → **0 visits, 0 finds** — "not a +shopping town, a road network with occasional shops"). Both pass every other law. So `validateTownCache` +now **warns when `medianShopSpacing(cache) > MAX_MEDIAN_SPACING_M` (150 m)**: +- **The metric** (exported from the barrel — import it, don't re-derive it): for each shop, the distance to + its **nearest other shop**; take the **median**. Equirectangular metres about `center.lat`, the same + convention as the span check. `null` for < 2 usable shops. +- **Warn, never error.** A flagged cache still boots a valid district — toowoomba shipped green through the + entire v4.0 pack. **E curates** (re-bbox onto the retail strip, or retire the town, counted either way); + the validator only flags. The span warn catches a town too **wide**; this catches one too **sparse**, a + case span cannot see (toowoomba is *inside* the 5 km law). +- **The threshold is measured, not chosen.** Across the 23-town pack the line is **forced** into + `(119.3, 254.9]`: braddon at 119.3 m is alive by D's own hub test (7 shops within 160 m — the same as + darwin's 7), and ballarat at 254.9 m is not. 150 m sits in that gap — the pack's widest (**×2.14**) — + with 1.26× margin below and 1.70× above. It flags **2 of 23**: ballarat 255 m, toowoomba 396 m. +- **⚠ CACHE space, not PLAN space — they do not agree.** This runs on raw lat/lon, before any lift; D's + notes quote **plan** figures (post-lift metres). The lift marches shops at a regularised cadence, so it + **compresses** dense towns (darwin **68.3 m cache → 27.2 m plan**) and leaves sparse ones alone + (toowoomba 396.1 → 386.8). Both numbers are right; they measure different spaces. Cache space is correct + *here* — the validator's job is to flag a bad bbox **at ingest**, before a lift exists — and it is + faithful exactly where the warn lives (ballarat plan/cache **1.01**, toowoomba **0.98**). Known limit: + one town flips (`newtown_godverse`, cache 94.4 → plan 158.3); no cache-space line can catch it without + false-positiving braddon. See `LANE_A_NOTES` §R23. + **Wiring:** `generatePlanOSM(seed, key, { cache })` marches a cache; `registerTownCache(key, cache)` adds it to the registry (`osmTownKeys()` + `generatePlanFor(seed,'osm',{town:key})` then see it). `selfcheck.js` auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig suites, pins a per-town golden. diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 990f23c..ba8b5bd 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,56 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 23 (2026-07-16) — v5.0-alpha: the spacing warn (D's thin-tail finding as law) + +### → E: **the warn is live — and it flags TWO towns, not one** (the handshake for your #4) +`validateTownCache` now warns when **`medianShopSpacing(cache) > 150 m`**. Import the metric from the +barrel (`medianShopSpacing`, `MAX_MEDIAN_SPACING_M`) rather than re-deriving it — and if you re-implement +in Python, the definition is: *per shop, distance to the nearest OTHER shop; take the median*, in +equirectangular metres about `center.lat`. Warn, never error: **you curate, I only flag.** + +**Use the warn as your acceptance test.** Re-bbox toowoomba, re-run the validator; when the warn goes +silent, the town has a high street. That's the whole loop, no judgement call needed. + +| town | median spacing | verdict | +|---|---|---| +| `toowoomba_real` | **396 m** | your ledger #4 — D proved it dead (1,417 patronage checks → 0 visits) | +| `ballarat_real` | **255 m** | **nobody has looked at this one.** Same class, 1.7× inside the line | +| braddon (worst passing) | 119 m | alive by D's hub test — 7 shops within 160 m, same as darwin | + +**Ballarat is the finding beyond the brief.** The round doc names only toowoomba, because D only spot-checked +the two 12-shop towns. Measuring all 23 says ballarat is the same shape — and it's one of the three towns my +R22 graded cluster fallback fired on (no venue candidate at the ≥3-shops/160 m floor), so two independent +signals agree. Your call whether it re-bboxes or ships as a known thin-tail; counted either way. **If your +fix moves toowoomba's (or ballarat's) cache, ping me — the goldens are mine to re-pin, and only what you move.** + +### ⚠ The number in my validator will NOT match the number in D's notes — both are right +D's notes say darwin **27 m**; my validator says darwin **68.3 m**. Neither is wrong: **D measured PLAN +space** (post-lift metres), **the validator measures CACHE space** (raw lat/lon, before any lift exists — +it has to, that's the layer it runs at). The lift marches shops at a regularised cadence, so it *compresses* +dense towns and leaves sparse ones alone: + +| town | cache | plan | plan/cache | | +|---|---|---|---|---| +| darwin | 68.3 | 27.2 | **0.40** | dense ⇒ the lift compresses hard | +| toowoomba | 396.1 | 386.8 | **0.98** | sparse ⇒ the lift barely moves it | +| ballarat | 254.9 | 257.7 | **1.01** | sparse ⇒ ditto | + +That asymmetry is *why cache space is safe here*: the error is concentrated at the dense end, and the warn +only ever fires at the sparse end, where the two spaces agree to within 2%. I verified all 23 towns in both +spaces — **exactly one disagrees.** + +### → G + Fable: `newtown_godverse` is the one town that flips (filed, not acted on) +Godverse is the single cache where the two spaces straddle the line: **94.4 m cache (passes) → 158.3 m plan +(would warn)**. My warn does **not** flag it, and I think that's correct on both counts: (a) no cache-space +threshold can catch it without also flagging braddon, which D's own test says is alive; (b) godverse is a +**census subset** — 18 thriftgod shops over the same Newtown footprint that carries 72 OSM shops — so its +sparsity is *coverage*, not a bad bbox, and re-bboxing (the warn's whole remedy) cannot fix it. + +**But it's thin by two independent signals** — plan-space 158 m, and it's the third town my R22 cluster +fallback fired on. Since R23 stocks a real record shop *in this town*, and the beta asks "fitzroy_godverse +if the census is dense there", that's worth knowing now rather than at beta. Not my call — filing it. + ## Round 22 (2026-07-16) — v4.0: the pack absorbed, 36 goldens pinned (the epoch close) ### → C + D + F: **the pack is in — 23 towns, all pinned, start your verifies** (the handshake) diff --git a/web/js/citygen/index.js b/web/js/citygen/index.js index 5b4f665..12b32a6 100644 --- a/web/js/citygen/index.js +++ b/web/js/citygen/index.js @@ -9,7 +9,8 @@ export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; export { shopName, townName, bandName } from './names.js'; -export { generatePlanOSM, osmTownKeys, registerTownCache, validateTownCache, MIN_TOWN_SHOPS } from './plan_osm.js'; +export { generatePlanOSM, osmTownKeys, registerTownCache, validateTownCache, MIN_TOWN_SHOPS, + medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js'; // The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract // from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index c3d8caa..01d8cd4 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -54,6 +54,46 @@ const ACCEPTED_SCHEMAS = new Set(['procity-town-cache/1', 'procity-town-cache/2' export const MIN_TOWN_SHOPS = 6; // functional floor: up to 4 venues + the one openLate landmark + a spare export const MAX_TOWN_SPAN_M = 5000; // a cache should be ONE compact town, not a region (see R17 risk note) +// ── the spacing floor (ROUND23 ledger #5 — D's R22 thin-tail finding, made law) ────────────────── +// MIN_TOWN_SHOPS counts shops; what makes a town READ ALIVE is clustering. D measured both 12-shop +// towns in the pack and they disagree — that disagreement is the whole finding: +// darwin_real ALIVE — shops sit on one strip; 1455 patronage checks → 18 visits. +// toowoomba_real DEAD — shops scattered over a 4.6 km road network; 1417 checks → 0 visits, 0 finds. +// "Not a shopping town — a road network with occasional shops." +// Both pass MIN_TOWN_SHOPS. One has a high street; the other doesn't. So: WARN, never error — E curates +// (re-bbox or retire, counted either way), the validator only flags. A flagged cache still boots a valid +// district, which is why this can't be an error: toowoomba shipped green in the whole v4.0 pack. +export const MAX_MEDIAN_SPACING_M = 150; + +// D's metric: for each shop, the distance to its NEAREST OTHER shop; take the median. Exported so every +// lane measures the same thing (the R13-debt-#5 pattern — publish the law as a function, not a paragraph +// each lane re-implements). Returns null for a cache with < 2 usable shops (nothing to space). +// +// SPACE WARNING — this is CACHE space (raw lat/lon), not PLAN space (post-lift metres), and the two do +// NOT agree. The lift marches shops onto real edges at a regularised cadence, so it COMPRESSES dense +// towns (darwin: 68.3 m cache → 27.2 m plan) while leaving sparse ones alone (toowoomba: 396.1 → 386.8). +// D's notes quote PLAN figures (27 / 388); this function on the same towns returns 68.3 / 396.1. Both are +// right — they measure different spaces. Cache space is the correct one HERE: the validator's job is to +// flag a bad bbox at ingest, before any lift exists, and it is faithful exactly where the warn lives +// (the sparse end: ballarat plan/cache 1.01, toowoomba 0.98). See LANE_A_NOTES §R23 for the full table. +export function medianShopSpacing(cache) { + const shops = (cache && Array.isArray(cache.shops) ? cache.shops : []).filter(s => s && isNum(s.lat) && isNum(s.lon)); + if (shops.length < 2) return null; + const lat0 = (cache.center && isNum(cache.center.lat)) ? cache.center.lat : shops[0].lat; + const kx = 111320 * Math.cos(lat0 * Math.PI / 180), kz = 111320; // same equirectangular convention as the span check + const nn = shops.map(s => { + let best = Infinity; + for (const o of shops) { + if (o === s) continue; + best = Math.min(best, Math.hypot((s.lon - o.lon) * kx, (s.lat - o.lat) * kz)); + } + return best; + }); + nn.sort((a, b) => a - b); + const m = nn.length >> 1; + return nn.length % 2 ? nn[m] : (nn[m - 1] + nn[m]) / 2; +} + // ── roads[] (schema v2, ROUND18) ───────────────────────────────────────────────────────────────── // OSM `highway=*` → CityPlan edge kind. The graph lift refines this with shop density (a `side` road // dense with shops becomes the `main` spine). Anything unmapped defaults to 'side'. @@ -118,6 +158,13 @@ export function validateTownCache(cache) { const latM = (mxLat - mnLat) * 111320, lonM = (mxLon - mnLon) * 111320 * Math.cos((c && c.lat || 0) * Math.PI / 180); if (Math.max(latM, lonM) > MAX_TOWN_SPAN_M) warnings.push(`shops span ${(Math.max(latM, lonM) / 1000).toFixed(1)} km — likely more than one town (bound the query tighter)`); } + // thin-tail warning (ROUND23 ledger #5): the span check catches a town that's too WIDE; this catches + // one that's too SPARSE to read as a town at all — D's finding. Metadata only, like the span warn: + // plan_osm still boots a valid district, it just has no high street to walk. + const spacing = medianShopSpacing(cache); + if (spacing !== null && spacing > MAX_MEDIAN_SPACING_M) { + warnings.push(`median shop spacing ${Math.round(spacing)} m > ${MAX_MEDIAN_SPACING_M} m — shops are scattered, not a high street (re-bbox onto the retail strip, or retire the town)`); + } } // v2: optional roads[] — simplified OSM ways. Each is `{ kind, pts:[[lat,lon],…] }` (id/name optional); // `kind` is the OSM highway=* class. Absent (or a v1 cache) ⇒ the marched-avenue fallback, so nothing diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 9054f48..bb4ead3 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -8,7 +8,8 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; -import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS } from './plan_osm.js'; +import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS, + medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js'; import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js'; import { xmur3 } from '../core/prng.js'; @@ -400,6 +401,40 @@ for (const [label, cache, needle] of [ const v = validateTownCache(wide); ok(v.ok && v.warnings.some(w => /span|town/.test(w)), `wide-spread cache: valid but warns (${v.warnings.find(w => /span/.test(w)) || 'NO WARN'})`); } +// (c2) the SPACING warn (ROUND23 ledger #5 — D's thin-tail finding as law). The span warn catches a town +// that's too WIDE; this catches one too SPARSE to read as a town. Warn, never error — and the town must +// still boot green, because that's exactly what toowoomba did through the entire v4.0 pack. +{ + // a scattered town: 12 shops ~400 m apart (toowoomba's shape) — inside the 5 km span law, so the span + // warn does NOT fire; this is the gap D found and the reason the metric exists. + const scattered = mkCache(Array.from({ length: 12 }, (_, i) => ({ + id: 600 + i, name: `Scattered ${i}`, type: 'opshop', + lat: -33.7 + (i % 4) * 0.0036, lon: 150.3 + Math.floor(i / 4) * 0.0036, suburb: 'Testville', + }))); + const v = validateTownCache(scattered); + ok(v.ok, `sparse cache: still VALID (warn, not error) — a flagged town boots a real district`); + ok(v.warnings.some(w => /median shop spacing/.test(w)), `sparse cache: warns (${v.warnings.find(w => /spacing/.test(w)) || 'NO WARN'})`); + ok(!v.warnings.some(w => /span/.test(w)), `sparse cache: the SPAN warn stays silent — spacing catches what span cannot`); + structuralSuite(generatePlanOSM(20261990, 'sparse', { cache: scattered }), `sparse-warn town`); + districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'sparse', cache: scattered }), `sparse-warn town`); + + // a compact town must NOT warn (no false positive on the shape 20 of 23 pack towns have) + const tight = validateTownCache(mkCache(cluster(14))); + ok(!tight.warnings.some(w => /median shop spacing/.test(w)), `compact cache: no spacing warn (no false positive)`); + + // the metric itself — D's definition: per shop, distance to the NEAREST OTHER shop, then the median. + ok(medianShopSpacing(mkCache(cluster(14))) < MAX_MEDIAN_SPACING_M, `medianShopSpacing: compact town under the floor`); + ok(medianShopSpacing(mkCache(cluster(14, { samePoint: true }))) === 0, `medianShopSpacing: coincident shops → 0 m`); + ok(medianShopSpacing(mkCache(cluster(1))) === null, `medianShopSpacing: < 2 shops → null (nothing to space)`); + ok(medianShopSpacing({ shops: [] }) === null, `medianShopSpacing: no shops → null, no throw`); + { + // exact-value check: two shops 100 m apart in latitude → both nearest-neighbours are 100 m → median 100. + const pair = mkCache([{ id: 1, name: 'A', type: 'opshop', lat: -33.7, lon: 150.3 }, + { id: 2, name: 'B', type: 'opshop', lat: -33.7 + 100 / 111320, lon: 150.3 }]); + const got = medianShopSpacing(pair); + ok(Math.abs(got - 100) < 0.5, `medianShopSpacing: 100 m pair measures ${got.toFixed(1)} m (projection sane)`); + } +} // (d) the registry seam: register → osmTownKeys() sees it → generatePlanOSM resolves it { registerTownCache('regtest', mkCache(cluster(14)));