Lane A round 23 (v5.0-alpha): the spacing warn — D's thin-tail finding as law (ledger #5)

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 23:04:43 +10:00
parent f14e4d1974
commit 292b07aa91
6 changed files with 191 additions and 6 deletions

View File

@ -1,11 +1,37 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (161,300/161,300); rounds 222 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 223 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

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)));