Lane A R39 39.1 (close): map.html says the street name out loud; CITY_SPEC + A-progress §39

map.html (Lane A's reference tool) can now load E's REAL town caches — until this round it could
only render the three checked-in fixtures, so it could never show a real street. Adds the address
line to the panel and `label · side` to the shop tooltip.

Verified in-browser (no-store server, both suppliers, ONE line of code, no town-type branch):
· ?plansrc=osm&town=castlemaine_real → "address: ways · 841/858 edges named · 22/22 shops ·
  141 names"; tooltip "XXXX Antiques — Elizabeth Street · south side", "Maxi IGA — Forest Street ·
  north side". Matches the Node measurement shop-for-shop.
· default synthetic → "address: district · 493/493 shops labelled by district"; tooltip
  "Nev's Pawn & Loan — the warehouse fringe · north side".

CITY_SPEC: the address layer documented as DERIVED data, with the schema rule that follows from it —
no street name, district label or address ever becomes a CityPlan field. `plan_osm` still discards
`rd.name` deliberately; the names are recovered downstream because plan nodes ARE snapped projected
way points. The only thing the lift publishes is `norm.shift`, into opts.report, never the plan.

A-progress §39: the round, the two departures from the synthesis with their numbers, the gate table
with the breakage that fires each arm, and the tolerance finding (correctness saturates at 6 m).

selfcheck 157,647/157,647 ALL GREEN · fingerprint 0x5f76e76 unchanged · zero re-pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-03 20:39:59 +10:00
parent a569f08b14
commit 34f7dae397
3 changed files with 132 additions and 3 deletions

View File

@ -1,11 +1,90 @@
# LANE A — CITYGEN · progress (PROCITY-A) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check ALL GREEN (157,407/157,407); rounds 230 + 37 closed**. *Status: **all deliverables landed; self-check ALL GREEN (157,647/157,647); rounds 230 + 37 + 39 closed**.
R39 (v9 item 39.1): THE ADDRESS LAYER — 17,835 named ways stop being dropped on the floor. New pure
module `address.js`; **1,192/1,219 corpus shops (97.8%) resolve to a real street name**, 0 wrong names
on 884 shop-bearing edges against an independent resolver, **all goldens frozen, zero re-pins.**
R37 (v8 wave 0 §0.1): the poster-clearance gate was VACUOUS — it validated my own placer against my own R37 (v8 wave 0 §0.1): the poster-clearance gate was VACUOUS — it validated my own placer against my own
law and stayed green while all 14 posters on the default boot stood on B's bitumen. Corrected to three law and stayed green while all 14 posters on the default boot stood on B's bitumen. Corrected to three
arms with a permanent control; proven to fail on the pre-ruling geometry where the old gate cannot. arms with a permanent control; proven to fail on the pre-ruling geometry where the old gate cannot.
All goldens frozen, zero re-pins.* All goldens frozen, zero re-pins.*
## Round 39 (2026-08-03) — v9 item 39.1: THE ADDRESS LAYER (the approved thing; B and C consume it)
**The full contract, per-town table and reasoning live in `LANE_A_NOTES.md §39`** — published in the
first commit, because B (39.2 the fog) and C (39.3 the op-shop dig) build against it in parallel.
**Fable's three binding facts, re-derived independently — all three land exactly.** 19,132 road ways,
**17,835 named (93.2%)**; median plan-edge-sample → nearest named way **0.030.90 m** (worst katoomba);
**1,192 / 1,219 corpus shops (97.8%)** resolve. Nothing to contradict.
**What landed.** `web/js/citygen/address.js` — pure, **zero imports**, no THREE/fetch/DOM/module state,
no plan mutation. `createAddresses(plan, cache|null)``streetOf` · `localityOf` · `cohort` ·
`streets` · `stats`. Two suppliers, one field: a cache town fills `label` from real named ways, the
synthetic from `district.kind` + block. Plus `getTownCache(key)` (additive, read-only) because
`index.html:129` registers the cache and drops its reference, so `roads[]` was unreachable downstream.
`map.html` now loads real caches and prints the address — verified in-browser on castlemaine (*"XXXX
Antiques — Elizabeth Street · south side"*) and on the synthetic (*"Nev's Pawn & Loan — the warehouse
fringe · north side"*) **through the identical line of code**.
**Two departures from the synthesis, both measured.**
1. **The shift is recovered EXACTLY, not voted for.** I implemented the proposed centroid-align +
nearest-waypoint vote first: it returns the **WRONG shift on 5 of 23 towns** (braddon, fremantle,
hobart, northbridge, westend) — island culling drags the plan centroid off the cache's and the vote
converges *confidently* on a wrong offset. A wrong shift renames every street in the town. Replaced
with a lattice intersection (node waypoint candidates, filtered by further nodes): **exact on
23/23, nothing to tune, returns null rather than a guess.**
2. **Five samples along an edge, not the midpoint.** A midpoint cannot distinguish a street from the
street that crosses it — at an intersection both are ~0 m away.
**The one-line upgrade: TAKEN, and it is genuinely that clean.** `norm.shift = {shx, shz}` goes onto the
normalization log, reaching the caller only via the existing `opts.report` sink — never the plan object.
It is a **cross-check, not a dependency**: the selfcheck compares plan_osm's published shift against
address.js's independent recovery and fires on a **0.01 m** disagreement. Without it the recovery would
only be checkable against itself, which is the exact vacuous-gate species R37 was about.
**The gates (+240 checks), each proven to fire on a broken world.**
| arm | today | breakage that fires it |
|---|---|---|
| 0 wrong names on shop-bearing edges, vs an **independent way-membership resolver** | **857/857 agree, 0 wrong** | resolver or shift drift |
| corpus resolution ≥ 97% | 1,192/1,219 (97.8%) | names stripped → 0.0% |
| shift recovered == plan_osm's published shift | exact, 23/23 | a **0.01 m** error |
| `createAddresses` does not mutate the plan | byte-identical per town | any write to `plan` |
| CONTROL: `roads[].name` stripped (**the pre-change state**) | 0 streets, supplier `district` | — |
| CONTROL: no cache passed | supplier `district`, visible in `stats()` | — |
| all-edge disagreements **PINNED at 4** (not `> 0`) | 4/30,986 | a control drifting to zero |
The 4 corpus disagreements are all 2D-stacked ways where the question has no single answer (geelong's
two adjacent malls; a motorway crossing *over* a street on glebe and redhill ×2) and **none carries a
shop** — which is why the shop-bearing arm is 0.
**The tolerance finding, which is the one worth keeping.** Coverage rises with tolerance (2 m → 85.2%,
6 m → 97.4%, 8 m → 97.8%, 12 m → 99.1%) but **correctness saturates at 6 m**: 857 agreeing frontage
names at 6, 8, 12, 16 *and* 24 m. Every metre past 6 buys exactly one thing — a name attached to a way
OSM left unnamed (0 at 6 m, 4 at 8 m, 14 at 24 m). **It can never buy a correction.** Shipped at the
briefed 8 m, which keeps four borrowings, all arcade footways inside a named mall.
**And the resolver degrades to null, not to a wrong name.** Feed it a shift wrong by 30 m and
castlemaine/katoomba/adelaide return **zero** street names rather than a town full of wrong ones. Feed
it one wrong by 3 m and adelaide's coverage *rises* from 89 to 98 shops while being wrong — which is
why the shift arm is exact-or-nothing and **coverage is never used as a proxy for correctness.**
**The failure modes, named.** All 27 unresolved shops front a way OSM genuinely leaves unnamed:
adelaide 21 `arcade` (its arcades are the town's retail spine and OSM leaves the footways unnamed — it
is the corpus worst at 80.9% and it is *honestly* worst), fitzroy 1, geelong 1, launceston 1 + 2 `lane`,
northbridge 1. **`null` is the honest answer and the module returns it**; a shop with `street: null`
carries `label: null` too, asserted per town.
**The synthetic's honest limit:** 12 label cells over 493 shops, median 41, max 147. Coarse — the same
structural fact §8 of the charter records. `block` is the finer cell (32 shop-bearing blocks, median 14).
**GOLDENS: NOTHING MOVED, ZERO RE-PINS.** synthetic `0x5f76e76`, gig `0xec7a2d39`, 3 osm fixtures, 23
`REAL_TOWN_GOLDENS`, 23 `REAL_TOWN_GIG_GOLDENS` — all untouched. Selfcheck **157,407 → 157,647 ALL
GREEN**. `createAddresses` costs **≤27 ms** on the worst real town (bendigo), **0.3 ms** on the
synthetic, once at plan time; zero draws, zero triangles, zero plan fields.
## Round 37 (2026-08-03) — v8 WAVE 0 §0.1: THE KERB (ruling-side) — the gate I owned was the vacuous one ## Round 37 (2026-08-03) — v8 WAVE 0 §0.1: THE KERB (ruling-side) — the gate I owned was the vacuous one
Full detail in LANE_A_NOTES §37. Four jobs, all measured before they were written. Full detail in LANE_A_NOTES §37. Four jobs, all measured before they were written.

View File

@ -572,6 +572,34 @@ nothing outside the cache-schema path).
> count beats teleport.** The frozen set is unmoved by the entire epoch: synthetic `0x3fa36874`, gig > count beats teleport.** The frozen set is unmoved by the entire epoch: synthetic `0x3fa36874`, gig
> `0xb1d48ea1`, marched fixtures, classic. > `0xb1d48ea1`, marched fixtures, classic.
### The address layer (v9, ROUND39 item 39.1) — DERIVED, never stored on the plan
`roads[].name` has always ridden the v2 cache (**17,835 of 19,132 ways carry one — 93.2%**) and
`plan_osm.buildRealRoads` has always dropped it: `rawWays.push({ kind, pl })` keeps the kind and the
geometry and discards the name. **It still does, deliberately.** Street names are recovered
*downstream* by `web/js/citygen/address.js` — a pure module (zero imports) that reads a plan plus its
cache and returns a query object:
```js
createAddresses(plan, cache|null) → streetOf(edgeId) · localityOf(shopId) · cohort(pred) · streets() · stats()
```
**THE RULE, and it is a schema rule, not a style note: no street name, district label or address ever
becomes a CityPlan field.** The plan is the frozen producer contract; addressing is derived data with a
97.8% resolution rate and a 2.2% honest-`null` tail, and baking either into `plan.lots`/`plan.shops`
would move all 46 pinned goldens to store something a consumer can compute in ≤27 ms. It is recoverable
because plan nodes ARE snapped projected way points (`SNAP=3`), so a plan edge lies **on** its way —
median sample-to-way distance **0.030.90 m** across the 23 caches.
The one thing the lift now publishes is its own centring translation, `norm.shift = {shx, shz}`, which
reaches the caller **only through the existing `opts.report` sink and never through the plan object**
so `JSON.stringify(plan)` is byte-identical and every golden is frozen by construction. It exists as a
**cross-check**: `address.js` re-derives the same shift independently, and the selfcheck compares the
two. Full contract, per-town rates and failure modes in `LANE_A_NOTES.md §39`.
**Consumers must not branch on town type.** `localityOf(shopId).label` is a real street name on a cache
town and a district phrase (*"the market end"*) on the synthetic — one field, two suppliers, one contract.
## The stock ladder (v5 — THE REAL SHOP; frozen at `v5.0`, ROUND27 ledger #6) ## The stock ladder (v5 — THE REAL SHOP; frozen at `v5.0`, ROUND27 ledger #6)
> **v4 made the towns real; v5 makes the shops real.** The map-level contract above is unchanged by this > **v4 made the towns real; v5 makes the shops real.** The map-level contract above is unchanged by this

View File

@ -86,7 +86,7 @@
<!-- No importmap / no THREE: this debug page is pure Canvas 2D (CITY_SPEC allows the map page to <!-- No importmap / no THREE: this debug page is pure Canvas 2D (CITY_SPEC allows the map page to
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. --> use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
<script type="module"> <script type="module">
import { generatePlanFor, chunkIndex, CHUNK } from './js/citygen/index.js'; import { generatePlanFor, chunkIndex, CHUNK, createAddresses, registerTownCache, getTownCache } from './js/citygen/index.js';
// plan source: default 'synthetic'; ?plansrc=osm renders a real-data fixture town, ?town=<key> picks it. // plan source: default 'synthetic'; ?plansrc=osm renders a real-data fixture town, ?town=<key> picks it.
const _q = new URLSearchParams(location.search); const _q = new URLSearchParams(location.search);
const PLANSRC = _q.get('plansrc') === 'osm' ? 'osm' : 'synthetic'; const PLANSRC = _q.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
@ -112,6 +112,7 @@ const cv = document.getElementById('c'), ctx = cv.getContext('2d');
let plan = null, idx = null, DPR = Math.min(devicePixelRatio||1, 2); let plan = null, idx = null, DPR = Math.min(devicePixelRatio||1, 2);
const view = { x:0, z:0, scale:0.6 }; // world→screen: screen = (world - viewcenter)*scale + canvascenter const view = { x:0, z:0, scale:0.6 }; // world→screen: screen = (world - viewcenter)*scale + canvascenter
let hover = null; let hover = null;
let addr = null; // ROUND39 39.1 — the address layer for the current plan (rebuilt in regen)
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const cssPx = () => ({ w: cv.clientWidth, h: cv.clientHeight }); const cssPx = () => ({ w: cv.clientWidth, h: cv.clientHeight });
@ -290,8 +291,12 @@ addEventListener('mousemove', e=>{
cv.style.cursor='pointer'; cv.style.cursor='pointer';
const l=hover.lot, s=hover.shop; const l=hover.lot, s=hover.shop;
tip.style.display='block'; tip.style.left=sx+'px'; tip.style.top=sy+'px'; tip.style.display='block'; tip.style.left=sx+'px'; tip.style.top=sy+'px';
// ONE consumer contract: print `label`. It is the street name on a real town and the district
// phrase on the synthetic, and this line does not know or care which.
const loc = s ? addr.localityOf(s.id) : null;
const where = loc && loc.label ? `<div class="s" style="color:var(--accent)">${esc(loc.label)}${loc.side?` · ${loc.side} side`:''}</div>` : '';
tip.innerHTML = s tip.innerHTML = s
? `<div class="t">${esc(s.name)}</div><div class="s">${s.type} · ${SHOP_TYPES[s.type]?.label||''} · ${s.storeys}fl · ${pad(s.hours[0])}${pad(s.hours[1])}</div><div class="s">${s.facadeSkin}</div>` ? `<div class="t">${esc(s.name)}</div>${where}<div class="s">${s.type} · ${SHOP_TYPES[s.type]?.label||''} · ${s.storeys}fl · ${pad(s.hours[0])}${pad(s.hours[1])}</div><div class="s">${s.facadeSkin}</div>`
: `<div class="t">${l.use} lot</div><div class="s">${l.w}×${l.d}m · block ${l.block}</div>`; : `<div class="t">${l.use} lot</div><div class="s">${l.w}×${l.d}m · block ${l.block}</div>`;
scheduleDraw(); scheduleDraw();
} else { hideTip(); } } else { hideTip(); }
@ -313,6 +318,10 @@ const pad=h=>String(h).padStart(2,'0')+':00';
function regen(seed){ function regen(seed){
const s = (seed>>>0); const s = (seed>>>0);
plan = generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS, customBands }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan plan = generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS, customBands }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
// ROUND39 39.1 — THE ADDRESS LAYER on the reference map. Same two-arg call every consumer makes,
// and NO branch on town type: a registered real cache supplies street names, everything else
// supplies district labels, and the tooltip prints `label` either way.
addr = createAddresses(plan, TOWN ? getTownCache(TOWN) : null);
$('seed').value = s; $('seed').value = s;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`; $('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={}; const nShop=plan.shops.length, byType={};
@ -320,6 +329,11 @@ function regen(seed){
$('stats').innerHTML = $('stats').innerHTML =
`<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+ `<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+
`<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`; `<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`;
{ const a = addr.stats();
$('stats').innerHTML += `<br><span class="muted">address: <b>${a.supplier}</b> · `+
(a.supplier === 'ways'
? `${a.edgesNamed}/${a.edges} edges named · <b>${a.shopsWithStreet}/${a.shops}</b> shops · ${a.distinctStreets} names`
: `${a.shopsLabelled}/${a.shops} shops labelled by district`)+`</span>`; }
if (GIGS && plan.gigs && plan.gigs.length){ if (GIGS && plan.gigs && plan.gigs.length){
const venue=plan.shops.find(s=>s.venue), g0=plan.gigs[0]; const venue=plan.shops.find(s=>s.venue), g0=plan.gigs[0];
$('stats').innerHTML += `<br><b style="color:#ffd23f">♪ ${esc(venue?.name||'venue')}</b><br>`+ $('stats').innerHTML += `<br><b style="color:#ffd23f">♪ ${esc(venue?.name||'venue')}</b><br>`+
@ -362,6 +376,14 @@ requestAnimationFrame(async ()=>{
resize(); resize();
// OPTIONAL band drop-in: load John's names for the debug view (graceful if the file is absent). // OPTIONAL band drop-in: load John's names for the debug view (graceful if the file is absent).
if (GIGS){ try { const r=await fetch('./assets/custom_bands.json'); if(r.ok){ const j=await r.json(); if(Array.isArray(j.bands)) customBands=j.bands; } } catch(e){} } if (GIGS){ try { const r=await fetch('./assets/custom_bands.json'); if(r.ok){ const j=await r.json(); if(Array.isArray(j.bands)) customBands=j.bands; } } catch(e){} }
// ROUND39 39.1 — load E's REAL town cache when one exists for ?town=<key>, exactly the way the
// shell does (web/index.html:125-129). Until now this page could only render the three checked-in
// fixtures, so it could never show a real street. Graceful: a missing cache falls straight through
// to the fixture path, which is what melbourne/katoomba/silverton do.
if (PLANSRC === 'osm' && TOWN){
try { const r = await fetch(`./assets/towns/${TOWN}.json`);
if (r.ok) registerTownCache(TOWN, await r.json()); } catch(e){}
}
regen(bootSeed); regen(bootSeed);
}); });
</script> </script>