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)
*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
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.
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
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
> `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)
> **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
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
<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.
const _q = new URLSearchParams(location.search);
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);
const view = { x:0, z:0, scale:0.6 }; // world→screen: screen = (world - viewcenter)*scale + canvascenter
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 cssPx = () => ({ w: cv.clientWidth, h: cv.clientHeight });
@ -290,8 +291,12 @@ addEventListener('mousemove', e=>{
cv.style.cursor='pointer';
const l=hover.lot, s=hover.shop;
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
? `<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>`;
scheduleDraw();
} else { hideTip(); }
@ -313,6 +318,10 @@ const pad=h=>String(h).padStart(2,'0')+':00';
function regen(seed){
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
// 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;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};
@ -320,6 +329,11 @@ function regen(seed){
$('stats').innerHTML =
`<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`;
{ 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){
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>`+
@ -362,6 +376,14 @@ requestAnimationFrame(async ()=>{
resize();
// 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){} }
// 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);
});
</script>