diff --git a/docs/A-progress.md b/docs/A-progress.md index 45afb59..6d24dd6 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,11 +1,35 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (51866/51866); rounds 2–20 closed** (see below). -v4.0-beta: spine-poster cap scaled by shop count (katoomba 438→31, closes F's venue_night edge) + E's -density widening absorbed (5 towns at 3–6× shops, all place 3 venues, drops 5.5% counted by cause); all 10 -real-town goldens (base + gig) pinned. classic/gig/marched-fixtures frozen.* +*Status: **all deliverables landed; self-check all-green (66419/66419); rounds 2–21 closed** (see below). +R21: venue cluster-adjacency bias — D's relocation finding resolved (venues under 3 shops/160 m: 11 of 15 +→ 0); gig goldens re-pinned; synthetic frozen by construction. R20: poster cap (katoomba 438→31) + E's +density widening absorbed (5 towns at 3–6× shops). classic/gig/marched-fixtures frozen.* *Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* +## Round 21 (2026-07-16) — v4.0-beta: the venue cluster-adjacency bias (ledger #1, the round's spine) + +D filed it: katoomba's pub sat 1,000 m from the retail cluster — a gig with no passing crowd. Measuring all +five towns first showed it was **systemic: 11 of 15 venues** had <3 shops within 160 m (newtown's were +0/0/1). After the bias: **0 of 15**. Katoomba's pub went **1 → 9**; castlemaine cleared without needing the +floor exception Fable anticipated. + +- `pickVenues` now builds a **cluster-adjacent field** (≥`CLUSTER_MIN` 3 shops within `CLUSTER_R` 160 m), + then the kind flavors (pub corner / band_room fringe / rsl off-spine) bias **within** it rather than + overriding it (per the brief: biases, not absolutes). No cluster at all → falls back to the pool. +- **Synthetic frozen BY CONSTRUCTION**, not by luck. The brief assumed a blanket filter was a no-op because + "the synthetic town is one dense cluster anyway" — measurement says otherwise: **4 synthetic candidates + per seed** sit under the threshold (min cluster 0–1), so a blanket filter *would* have moved the frozen + golden. Gated on `plan.source === 'osm'` (undefined on synthetic). Marched fixtures are 'osm' but genuinely + dense (min 3/11/13) ⇒ verified no-op. +- **Only the GIG goldens moved** (venue selection is gig-layer): katoomba `0xbf586b77`, newtown `0xacdb3eee`, + fremantle `0xcf3426fc`, bendigo `0x646b2c23`, castlemaine `0xf9713a69`. Base goldens untouched. + classic/gig/marched fixtures frozen. selfcheck ALL GREEN **66419/66419**; scaffold + consistency GREEN; + cross-process deterministic. +- FYI: E's v4.0 pack (`glebe`, `marrickville`, `newcastle`) already boots clean through my loader (validates + + full suites pass), listed UNPINNED — **I pin the pack in R22**; it does not gate the beta tag. + +Handshake fired → C + D: cluster bias landed, goldens re-pinned, start your verifies. + ## 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. diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 9d499cb..675593d 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,45 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 21 (2026-07-16) — the venue cluster bias (D's relocation finding) + goldens re-pinned + +### → C + D: **cluster bias landed, goldens re-pinned — start your verifies** (the handshake) +**Only the GIG goldens moved** (venue selection is gig-layer; the base town goldens are untouched): +katoomba `0xbf586b77`, newtown `0xacdb3eee`, fremantle `0xcf3426fc`, bendigo `0x646b2c23`, +castlemaine `0xf9713a69`. Base unchanged (katoomba `0x420f677d`, …). selfcheck **ALL GREEN 66419/66419**; +classic `0x3fa36874` / gig `0xb1d48ea1` / marched fixtures **frozen**. + +### → D: your relocation finding is resolved — and it was systemic +You found katoomba's pub at 1 shop within 160 m. Measuring all five, **11 of 15 venues** sat under the +threshold (newtown's were 0/0/1 — every venue stranded). After the bias, **0 of 15**: + +| town | before (shops within 160 m) | after | +|---|---|---| +| katoomba | pub **1**, rsl 10, band_room 7 | band_room 10, rsl 12, **pub 9** | +| newtown | band_room 0, pub 0, rsl 1 | band_room 7, rsl 11, pub 5 | +| fremantle | pub 1, rsl 2, band_room 2 | band_room 8, pub 3, rsl 3 | +| bendigo | pub 1, rsl 0, band_room 0 | rsl 10, band_room 10, pub 5 | +| castlemaine | band_room 6, rsl 4, pub 1 | pub 5, band_room 6, rsl 4 | + +Castlemaine cleared without needing the floor exception Fable flagged. Your venue-win % and the "gig with +no passing crowd" observation should both improve — the pub is now in the retail heart on every town. + +### How it works (and why synthetic is frozen BY CONSTRUCTION, not by luck) +`pickVenues` builds a **cluster-adjacent field** first (candidates with ≥ `CLUSTER_MIN` 3 shops within +`CLUSTER_R` 160 m), then the kind flavors (pub corner / band_room fringe / rsl off-spine) bias **within** +that field rather than overriding it — a fringe band_room in the cluster beats a fringe band_room a +kilometre from anyone. A town with no cluster falls back to the whole pool. +**Gated on `plan.source === 'osm'`** — real-data towns only. Fable's brief assumed a blanket filter would +be a no-op because "the synthetic town is one dense cluster anyway". **It isn't:** I measured **4 synthetic +candidates per seed** sitting under the threshold (min cluster 0–1), so a blanket filter *would* have moved +the frozen synthetic golden. The `source` gate makes it impossible instead of unlikely. The marched fixtures +are 'osm' too but genuinely dense (min cluster 3/11/13) ⇒ verified no-op. + +### FYI: E's v4.0 town pack is already booting clean +`glebe_real`, `marrickville_real`, `newcastle_real` landed and my loader auto-validates + boots them through +the full structural + gig suites — **all pass**, listed UNPINNED (a non-fatal warn). That's per the brief: +**I pin the pack in R22**, and it must not gate the beta tag. Early signal: the pack needs no new hardening. + ## 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) diff --git a/web/js/citygen/gigs.js b/web/js/citygen/gigs.js index d6b1bc0..782023c 100644 --- a/web/js/citygen/gigs.js +++ b/web/js/citygen/gigs.js @@ -44,6 +44,9 @@ const GIGS_PER_VENUE = [4, 6]; // per week — the rest are dark nights, whic // Kind order as venues are added. A town of 4 gets a second pub at the far end of the spine — // two pubs and a footy club is the most Australian town there is. const VENUE_SEQUENCE = ['pub', 'band_room', 'rsl', 'pub']; +// ROUND21 (D's relocation finding): a venue 1 km from the retail cluster is a gig with no passing crowd +// (katoomba's pub had 1 shop within 160 m; the RSL had 13). Venue candidates prefer cluster adjacency. +const CLUSTER_R = 160, CLUSTER_MIN = 3; // Cover law (ROUND13 §A2): ~half free town-wide; the RSL skews CHEAP (it's a club, the bistro pays the // bills), the band room skews FREE (the door is a hat on a milk crate). `cover` stays an integer in @@ -92,6 +95,18 @@ function pickVenues(plan, citySeed) { } return best; }; + // ROUND21: how many OTHER shops sit within CLUSTER_R of this candidate — its retail-cluster adjacency. + // Real-data plans only: `plan.source === 'osm'` is undefined on the synthetic generator, so the bias + // below can never touch the frozen synthetic goldens BY CONSTRUCTION (not by luck — measured: 4 + // synthetic candidates per seed sit under CLUSTER_MIN, so a blanket filter WOULD have moved them). + const realData = plan.source === 'osm'; + const shopLots = realData ? plan.shops.map(lotOf).filter(Boolean) : []; + const clusterCount = s => { + const l = lotOf(s); if (!l) return 0; + let n = 0; + for (const o of shopLots) if (o !== l && Math.hypot(o.x - l.x, o.z - l.z) <= CLUSTER_R) n++; + return n; + }; const base = plan.shops.filter(s => { if (s.openLate) return false; // must not displace the night landmark @@ -108,15 +123,24 @@ function pickVenues(plan, citySeed) { const pool = base.filter(s => !taken.has(s.id)); if (!pool.length) break; // degenerate town — fewer venues, still valid + // ROUND21 ledger #1: prefer CLUSTER-ADJACENT candidates (the retail heart), then let the kind flavors + // bias WITHIN that set rather than override it — a fringe band_room in the cluster beats a fringe + // band_room a kilometre from anyone. A town with no cluster at all falls back to the whole pool. + let field = pool; + if (realData) { + const clustered = pool.filter(s => clusterCount(s) >= CLUSTER_MIN); + if (clustered.length) field = clustered; + } + let cand; if (kind === 'band_room') { - cand = pool.filter(s => blockKindOf(s) === 'warehouse'); - if (!cand.length) cand = pool.filter(s => !onMain(s)); // osm: no warehouse district + cand = field.filter(s => blockKindOf(s) === 'warehouse'); + if (!cand.length) cand = field.filter(s => !onMain(s)); // osm: no warehouse district } else if (kind === 'rsl') { - cand = pool.filter(s => !onMain(s) && blockKindOf(s) !== 'warehouse'); - if (!cand.length) cand = pool.filter(s => !onMain(s)); + cand = field.filter(s => !onMain(s) && blockKindOf(s) !== 'warehouse'); + if (!cand.length) cand = field.filter(s => !onMain(s)); } else { - cand = pool.filter(onMain); + cand = field.filter(onMain); // a SECOND pub belongs at the other end of the spine, not next door to the first const firstPub = out.find(v => v.kind === 'pub'); if (firstPub) { @@ -133,10 +157,11 @@ function pickVenues(plan, citySeed) { // the synthetic town every pub/rsl candidate already clears, so it stays a no-op there (golden frozen). { let clear = cand.filter(s => facadeClear(s) >= POSTER_CLEAR); - if (!clear.length) clear = pool.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR); + if (!clear.length) clear = field.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR); // stay in the cluster if (clear.length) cand = clear; } - if (!cand.length) cand = pool; // last resort: anything left + if (!cand.length) cand = field; // last resort: anything cluster-adjacent + if (!cand.length) cand = pool; // truly degenerate: anything left const shortlist = farthestFirst(cand).slice(0, Math.min(5, cand.length)); const shop = pick(rng(citySeed, 'venuepick', i), shortlist); if (!shop) break; diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 6809970..278b209 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -461,9 +461,11 @@ const REAL_TOWN_GOLDENS = { // 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. +// ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens +// move — venue selection is gig-layer, so the base town goldens above are untouched. const REAL_TOWN_GIG_GOLDENS = { - katoomba_real: 0x2115732a, newtown_real: 0x17ab31bb, fremantle_real: 0x82c27397, - bendigo_real: 0xf5ce58c4, castlemaine_real: 0x48272da9, + katoomba_real: 0xbf586b77, newtown_real: 0xacdb3eee, fremantle_real: 0xcf3426fc, + bendigo_real: 0x646b2c23, castlemaine_real: 0xf9713a69, }; // `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it. const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');