diff --git a/docs/A-progress.md b/docs/A-progress.md index 71f3e4f..8a1d45e 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,11 +1,30 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (51670/51670); rounds 2–19 closed** (see below). -v4.0-alpha alpha-close: fragmentation resolved (105→1 component on katoomba), E's spine-poster finding -fixed, all 5 real towns pinned on real-roads goldens (katoomba `0xb7ffbca2`). classic/gig/marched-fixtures -frozen.* +*Status: **all deliverables landed; self-check all-green (51675/51675); rounds 2–19 closed, R20 poster-cap +landed** (see below). v4.0-beta: spine-poster cap scaled by shop count (katoomba 438→31, closes F's +venue_night edge); real-town gig goldens pinned. classic/gig/marched-fixtures frozen. Density absorb pends +E's widened caches.* *Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* +## Round 20 (2026-07-16) — v4.0-beta THE FIVE TOWNS: the poster cap (density absorb pends E) + +Two Lane-A deliverables; the first (independent) landed this commit, the second waits on E's widened caches. + +1. **Poster cap (ledger #1) — LANDED.** My R19 finding (real katoomba: 438 spine posters, driving F's one + budget edge, `venue_night` 200,965 tris) is fixed. `gigs.js buildPosters` caps the spine pole run at + **`POSTER_PER_SHOP × shops`** (1.5/shop, floor 12) — a seeded subsample of the run. **Scaled by shop + count, not edge count** (the whole point: a sparse real town has few shops but a big graph). Result: + katoomba **438 → 31** posters (~2× the synthetic feel), fremantle 32, castlemaine 14. **No-op on the + synthetic town** (493 shops ⇒ cap ~740 ≫ its ~15 posters), so the synthetic gig golden `0xb1d48ea1` is + frozen. Posters are gig-layer, so the real-town BASE goldens are unchanged — I added + `REAL_TOWN_GIG_GOLDENS` (the full district+poster plan per town: katoomba `0xbd332e83`, …) to + regression-guard the capped output byte-exact. selfcheck ALL GREEN **51675/51675**; scaffold + consistency + GREEN; cross-process deterministic. → F: re-measure `venue_night`, the +965 edge should close. +2. **Density absorb (ledger #2) — PENDING E.** E re-fetches general retail (C's class→archetype table) and + re-emits the five caches at 3–6× shop count. When they land I load-test seating/overflow/venue placement + and re-pin the real goldens once. The poster cap already reads the live shop count → auto-scales to the + widened towns. + ## Round 19 (2026-07-16) — v4.0-alpha CLOSE: fragmentation ruling + findings hardened + goldens pinned A was the round's spine (the A→E→A finalization deferred from R18). Every filed finding resolved; the tag is diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index cf5bd61..7e46e96 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -311,7 +311,11 @@ camera are all `+Z`; position `lot + (sin ry, cos ry)·(d/2+ε)`), turned to rea R15 it was on the `−Z` back and invisible from the street). So the poster never sits over a back-street's bitumen, **`pickVenues` prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** (ROUND16 ledger #6 — exported from the barrel; the selfcheck asserts the same floor). Plus a seeded run along -the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole +the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**; +a candidate that would overhang a **real** crossing carriageway is skipped (ROUND19, E's finding), and the +run is **capped at `POSTER_PER_SHOP × shops`** (ROUND20) — a sparse real town has few shops but a big edge +graph, so the raw "2 per edge" run reads busy (katoomba 438 → 31). The cap never binds on the shop-dense +synthetic town, so its gig golden is frozen. Different venues' posters share the same pole runs — that IS the district reading. `ry` follows the house convention (a poster's printed face = `(−sin ry, −cos ry)`, so `atan2` of the *away-from-viewer* normal); B places E's poster skin at `(x, z)` rotated `ry`; F wires "poster → that venue's tonight-gig". diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 3452086..6d5d92b 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,28 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 20 (2026-07-16) — v4.0-beta: the poster cap (density absorb pending E's widened caches) + +### → F: **poster cap landed — re-measure `venue_night`** (your +965-tri edge should close) +My R19 finding (438 posters on katoomba) is fixed. `gigs.js` now **caps the spine pole run at +`POSTER_PER_SHOP × shops` (1.5/shop, floor 12)** — a seeded subsample. Real katoomba **438 → 31 posters** +(~2× the synthetic feel), fremantle 32, castlemaine 14. Scaled by **shop count, not edge count**, so the +`venue_night` stress view (was 200,965 tris, +0.5% over 200k) should drop well under. Re-measure and close it. + +### Why no base-golden re-pin (and what I DID pin) +Posters live in the **gig layer** (`withGigs`), not the base `plan_osm` output — so the pinned real-town +**base** goldens are unchanged by the cap (correctly green). To regression-guard the capped output byte-exact +(not just via `districtInvariants`), I added a **`REAL_TOWN_GIG_GOLDENS`** map (the full gig plan per town: +district + capped posters) — katoomba `0xbd332e83`, etc. Synthetic gig golden `0xb1d48ea1` **frozen** — the +cap never binds on the shop-dense synthetic town (493 shops ⇒ cap ~740 ≫ its ~15 posters). + +### Density absorb — PENDING E's widened caches (ledger #2) +E re-fetches general retail (per C's class→archetype table) and re-emits the five caches at 3–6× shop count. +When they land (E→A "caches re-emitted"), I load-test seating capacity + overflow accounting (`norm.dropped` +stays honest) + venue placement, then re-pin the real goldens **once** (base + gig). The R18 overflow fence +is exactly the load-bearing part now — I'll widen edge capacity / split runs if a dense edge overwhelms. My +poster cap already reads the LIVE shop count, so it auto-scales to the widened towns (no change needed). + ## Round 19 (2026-07-16) — the alpha close: fragmentation resolved + findings hardened + goldens pinned ### → B / C / D / F: **`katoomba_real` boots on real roads, golden pinned, findings resolved** (the handshake) diff --git a/web/js/citygen/gigs.js b/web/js/citygen/gigs.js index b189c52..7763550 100644 --- a/web/js/citygen/gigs.js +++ b/web/js/citygen/gigs.js @@ -23,6 +23,10 @@ const r4 = v => Math.round(v * 10000) / 10000; // selfcheck asserts the same floor — import POSTER_CLEAR there, don't re-guess it. export const POSTER_CLEAR = 0.5; const FACADE_PROUD = 0.06; +// ROUND20 poster cap: the spine pole run is "2 per main edge", which on a sparse REAL town (few shops, big +// edge graph) reads busy (katoomba: 438). Cap it SCALED BY SHOP COUNT — a seeded subsample of the run. On +// the synthetic town the shop count is in the hundreds, so the cap never binds and the gig golden is frozen. +const POSTER_PER_SHOP = 1.5, POSTER_SPINE_FLOOR = 12; // nearest point on segment AB to P (same helper the selfcheck uses; kept local so gigs.js stays pure). const nearestSeg = (px, pz, ax, az, bx, bz) => { const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1; @@ -275,7 +279,7 @@ function buildPosters(plan, citySeed, venues, gigs) { }; const pr = rng(citySeed, 'posters', 0); const PER_EDGE = 2; - let k = 0; + const spineCands = []; for (const e of runs) { const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; @@ -291,9 +295,15 @@ function buildPosters(plan, citySeed, venues, gigs) { const nx = side > 0 ? -uz : uz, nz = side > 0 ? ux : -ux; const x = a.x + ux * len * t + nx * off, z = a.z + uz * len * t + nz * off; if (onCarriageway(r2(x), r2(z))) continue; // check the r2-rounded position `add` stores — overhangs a crossing → skip (E's R18 finding) - add(x, z, Math.atan2(nx, nz), bill[k++ % bill.length].gigId); + spineCands.push({ x, z, ry: Math.atan2(nx, nz) }); } } + // ROUND20 poster cap: thin the pole run to ~POSTER_PER_SHOP × shops (a seeded subsample), so a sparse + // real town reads like a main street, not a poster wall. cap ≫ candidates on synthetic ⇒ no-op ⇒ frozen. + const cap = Math.max(POSTER_SPINE_FLOOR, Math.round(POSTER_PER_SHOP * plan.shops.length)); + const chosen = spineCands.length > cap ? shuffle(rng(citySeed, 'postercap', 0), spineCands.slice()).slice(0, cap) : spineCands; + let k = 0; + for (const c of chosen) add(c.x, c.z, c.ry, bill[k++ % bill.length].gigId); return posters; } diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 933578f..6962a3c 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -457,6 +457,13 @@ const REAL_TOWN_GOLDENS = { katoomba_real: 0xb7ffbca2, newtown_real: 0x6f984c81, fremantle_real: 0xfb197000, bendigo_real: 0x21179b3b, castlemaine_real: 0x7c47f639, }; +// ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is +// gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density +// absorb to come) are regression-guarded byte-exact, not just by districtInvariants. +const REAL_TOWN_GIG_GOLDENS = { + katoomba_real: 0xbd332e83, newtown_real: 0xd36f09ca, fremantle_real: 0xca42f569, + bendigo_real: 0x93ae8765, castlemaine_real: 0x9a4e904c, +}; const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json')); if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)"); for (const f of townFiles) { @@ -470,8 +477,12 @@ for (const f of townFiles) { for (const s of GIG_SEEDS) districtInvariants(generatePlanFor(s, 'osm', { gigs: true, town: key, cache }), `real/${key} ${s}`); const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, key, { cache })))() >>> 0; const want = REAL_TOWN_GOLDENS[key]; - if (want === undefined) console.log(` ⚠ real/${key}: UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`); - else ok(hash === (want >>> 0), `real/${key}: golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`); + if (want === undefined) console.log(` ⚠ real/${key}: base UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`); + else ok(hash === (want >>> 0), `real/${key}: base golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`); + const ghash = xmur3(JSON.stringify(generatePlanFor(20261990, 'osm', { gigs: true, town: key, cache })))() >>> 0; + const gwant = REAL_TOWN_GIG_GOLDENS[key]; + if (gwant === undefined) console.log(` ⚠ real/${key}: gig UNPINNED — add REAL_TOWN_GIG_GOLDENS['${key}'] = 0x${ghash.toString(16).padStart(8, '0')}`); + else ok(ghash === (gwant >>> 0), `real/${key}: gig golden 0x${ghash.toString(16)} matches pinned 0x${(gwant >>> 0).toString(16)}`); } // ── 4. every facade skin referenced by the registry exists on disk ──────────────────