selfcheck ALL GREEN 156,352/156,352; consistency green. 51 goldens re-pinned in this one commit per the amendment law. scaffold_check is RED and CANNOT be green from here — the covenant hashes are duplicated in LANE F's files; exact values handed to F in LANE_A_NOTES §R27w5 (I don't edit other lanes' files). THE RULING'S PREMISE WAS WRONG, AND THE WRONG HALF WAS LOAD-BEARING. Wave 5 says "synthetic follows B's canon so every golden has been green over a wrong product since R18". Half right. Synthetic does NOT follow B's canon -- it is broken identically, and so is the marched path, and so are all three checked-in fixtures. It is not "every real town since R18". It is EVERY TOWN, EVERY PATH, SINCE v1. The ruling told me to measure the marched path rather than assume it was clean "because that's how this one survived nine rounds" -- so I measured all three, and the same discipline caught the premise itself. MEASURED FIRST, IN B'S CANON (+Z = the facade), BEFORE TOUCHING ANYTHING: synthetic seed 20261990 0 toward / 681 away (dot = -1.0000, exactly anti-parallel) marched katoomba/fremantle/godverse, fixtures melbourne/katoomba/silverton 0 toward, all away real-roads katoomba 0/72 <- B's number, reproduced 637 of 681 synthetic lots had their facade pointing at NO STREET AT ALL (44 hit a different street by corner coincidence). Then I confirmed it with my own eyes rather than trust the arithmetic: booted ?classic=1, stood on the synthetic main street at lot 0 -> three blank grey boxes. Walked BEHIND them into the block -> NUMBAT PLAYTHINGS, sign/door/windows/awning, facing the dirt. The classic covenant town has been a row of back walls since v1. After the fix, same camera, same seed, same ?classic=1: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS -- a strip of shopfronts facing the road. FOUR SITES, ONE SIGN, AND EVERY ONE OF THEM SAID SO IN ITS OWN COMMENT: plan.js:122 synthetic strip "facade faces back down the outward normal, to the street" <- it didn't plan.js:244 market row "the facade then faces -(outward) = +x" <- it didn't plan_osm:330 marched avenue "facade faces +z (north) to the avenue" <- due south plan_osm:443 real roads B's proven one-liner (B measured it, then reverted it -- A's file) All four were atan2(outward) where the intent was atan2(-outward). The code never did what its comment said, and CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. Nobody was wrong; the spec was. After: synthetic 681/681, marched + fixtures 100%, real-roads katoomba 72/72. THE COVENANT MOVED -- JOHN RULED IT, I DIDN'T. Fixing synthetic necessarily moves 0x3fa36874, frozen "forever" since R16. That is not Lane A's call, so I stopped and asked with the evidence. John's ruling: AMEND THE COVENANT, FIX ALL PATHS -- the covenant is anti-drift machinery, not a defect preservative. 51 pins in this commit: classic/synthetic 0x3fa36874 -> 0x5f76e76 gig 0xb1d48ea1 -> 0xec7a2d39 fixtures melbourne 0x9c7e76b3 · katoomba 0xf3aafec8 · silverton 0x6d74c4 23 real towns base + gig (46) Verified byte-identical across two fresh processes; pack-xor 0xa5058322. CITY_SPEC records the amendment as a ONE-TIME, ruling-gated precedent -- from this hash forward the law reads as it always did. A SECOND BUG THE FIX EXPOSED (fixed here, counted). buildRealRoads seated blocks at KERB = 4 m from the CENTRELINE, but a `main` carriageway is 10 m wide -- its kerb is at 5 m. EVERY MAIN-STREET BUILDING HAS STOOD 1 m INSIDE THE ROAD SINCE R18. The footprint never moved; only which face is the front -- so it was invisible until the shopfront turned around and its poster landed in the traffic lane (-1.07 m clearance, 6 real failures). Blocks now seat behind the kerb per A's own published corridor law (roadWidth/2 + FOOTPATH). Pack drops 6.4% -> 6.6%, counted, not hidden. THE CROSS-CONVENTION GATE -- built to F's lesson, not to a spec line. F proved that measuring lots against CITY_SPEC:71's own convention is SELF-AGREEMENT: 72/72 "facing" over a town of blank walls. So this gate restates nothing. It replicates LANE B'S OWN maths from buildings.js (toWorld(lot, 0, y, d/2 + 0.02)) and asks a physical question neither spec line can fudge: does the quad the RENDERER actually draws land nearer the street than the lot centre? It fires if A's ry sign drifts OR if B's zf sign drifts. It would have caught this on day one of v1. CITY_SPEC amendments: the lots contract now states facade/door/frontage = local +Z; the SIM/rig convention is documented separately as the different thing it is, with the explicit rule -- never assert a lot's facing against a spec line, assert it against the geometry the renderer draws. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
929 lines
69 KiB
Markdown
929 lines
69 KiB
Markdown
# LANE A — NOTES (cross-lane answers from CityGen)
|
||
|
||
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
|
||
|
||
## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones
|
||
|
||
### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them**
|
||
This is the one thing blocking your re-gate. `scaffold_check` is **RED** until you re-pin, and I can't
|
||
touch your files. Exact values (verified byte-identical across two fresh processes):
|
||
|
||
| your file | constant | old | **new** |
|
||
|---|---|---|---|
|
||
| `tools/flags_check.py:30` | `GOLDEN_HASH` | `0x3fa36874` | **`0x5f76e76`** |
|
||
| `tools/flags_check.py:31` | `GIG_GOLDEN` | `0xb1d48ea1` | **`0xec7a2d39`** |
|
||
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.melbourne` | `0x34cfdec0` | **`0x9c7e76b3`** |
|
||
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.katoomba` | `0x0f652510` | **`0xf3aafec8`** |
|
||
| `tools/qa/scaffold_check.mjs:225` | the comment quoting all three | — | same three |
|
||
| `tools/qa/tour_v5.py:42`, `tour_shots.py:10` | captions quoting `0x3fa36874` | — | **`0x5f76e76`** |
|
||
|
||
Also: `flags_check.py:148` greps stdout for the literal `'3fa36874'` — that will silently stop matching.
|
||
|
||
### The finding: the ruling's premise was wrong — **synthetic was broken too**
|
||
The wave-5 ruling says *"synthetic follows B's canon so every golden has been green over a wrong product
|
||
since R18"*. Half right, and the wrong half is load-bearing. **Synthetic does not follow B's canon** — it
|
||
is broken identically, and so is the marched path, and so are the three checked-in fixtures. It is not
|
||
"every real town since R18": it is **every town, every path, since v1**. Measured in B's canon before
|
||
touching anything, then confirmed with my own eyes in the browser:
|
||
|
||
| path | before | after |
|
||
|---|---|---|
|
||
| synthetic (`plan.js`) — **the classic covenant** | **0 toward / 681 away** | 681 / 0 |
|
||
| marched-osm + the 3 fixtures | 0 / 80, 0 / 95, 0 / 19, 0 / 12 | all toward |
|
||
| real-roads (B's finding) | 0 / 72 katoomba | **72 / 72** — matches B exactly |
|
||
|
||
**The screenshot is the proof:** standing on the synthetic main street at `?classic=1`, lot 0 — three blank
|
||
grey boxes. Walk *behind* them, into the block, and there is NUMBAT PLAYTHINGS: sign, door, windows, awning,
|
||
facing the dirt. Same camera after the fix: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS, a
|
||
strip of shopfronts facing the road. **637 of 681 synthetic lots had their facade pointing at no street at
|
||
all.**
|
||
|
||
**Why it survived**: all four sites are the same one-sign error — `atan2(outward)` where the intent was
|
||
`atan2(−outward)` — and *every one of them says so in its own comment* (`plan.js:122` "facade faces back
|
||
down the outward normal, to the street"). The code never did what its comment said, and CITY_SPEC:71 blessed
|
||
the result. Nobody was wrong; the spec was.
|
||
|
||
### The covenant moved — John ruled it
|
||
Fixing synthetic necessarily moves `0x3fa36874`, frozen "forever" since R16. That was not mine to decide.
|
||
**John's ruling: amend the covenant, fix all paths** — the covenant is anti-drift machinery, not a defect
|
||
preservative. **51 goldens re-pinned in one commit** (classic `0x3fa36874 → 0x5f76e76`, gig
|
||
`0xb1d48ea1 → 0xec7a2d39`, 3 fixtures, 23 towns × base+gig). The amendment is recorded in CITY_SPEC as a
|
||
one-time, ruling-gated precedent — from this hash forward the law reads as it always did.
|
||
|
||
### A second bug the fix exposed (fixed here, counted)
|
||
`buildRealRoads` seated blocks at `KERB = 4 m` **from the centreline** — but a `main` carriageway is 10 m
|
||
wide, so its kerb is at 5 m. **Every main-street building has stood 1 m inside the road since R18.** The
|
||
footprint never moved; only which face is the front — so it was invisible until the shopfront turned around
|
||
and its poster landed in the traffic lane (−1.07 m). Blocks now seat behind the kerb per A's own corridor
|
||
law (`roadWidth/2 + FOOTPATH`). Pack drops **6.4% → 6.6%**, counted, not hidden.
|
||
|
||
### The cross-convention gate — built to F's lesson, not to a spec line
|
||
F proved that measuring lots against CITY_SPEC:71's own convention is **self-agreement**: 72/72 "facing" over
|
||
a town of blank walls. So the new gate does not restate a spec line. It **replicates Lane B's own placement
|
||
maths** from `buildings.js` (`toWorld(lot, 0, y, d/2 + 0.02)`) and asks a physical question neither spec line
|
||
can fudge: *does the quad the renderer actually draws land nearer the street than the lot centre?* It fires
|
||
if A's `ry` sign drifts **or** if B's `zf` sign drifts. It would have caught this on day one of v1.
|
||
|
||
## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6)
|
||
|
||
### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag**
|
||
selfcheck **ALL GREEN 156,212/156,212**, classic `0x3fa36874` / gig `0xb1d48ea1` frozen, **no golden moved**
|
||
(none should — tier 2 touches no plan, no cache, no atlas bytes). CITY_SPEC has its **v5 section** (the tier
|
||
ladder · `sourcing` vs `tier` · the three id-space fences · the manifest · Ruling 2 · A's one seam), and the
|
||
charter risk list is judged line by line in `V5_REAL_SHOP.md`.
|
||
|
||
### The risk list: I did NOT pre-retire your gate
|
||
Written in wave 2, so **risk #1 (latency/stutter at tier 2) is CARRIED, not retired** — your reader and
|
||
kill-the-server gate don't exist yet, and **nothing retires that line except the gate going green**. Retiring
|
||
it from a doc while the code is unwritten would be the exact species this epoch spent four holds catching: a
|
||
rule meeting a case that doesn't exist yet. #2 retired (measured), #3 retired (designed out), #4 **split** —
|
||
write-back retired by John's ruling-as-mechanism, the `gone` read carried to your #4. Two new carried lines
|
||
(#6 orphaned atlas, #7 "real" is one shop wide).
|
||
|
||
### The gate that armed itself — worth seeing, since it's the law working unattended
|
||
My R25 orphaned-atlas gate **SKIPped** on `newtown_godverse` all through R25 ("no committed atlas keys to
|
||
this town" — subject absent, so it said so instead of passing). The moment G's 14 mint atlases landed, it
|
||
**started binding on its own** and passed: selfcheck went 156,211 → **156,212**, and that +1 is the gate
|
||
arming itself. Nobody edited it. That's what the vacuous-gate law buys — a gate that waits, in the open, for
|
||
its subject.
|
||
|
||
**Live near-miss on the books → G:** `redhill_godverse`'s lift drops keyed shop **2286 "Empire Revival"**;
|
||
you stocked its numeric neighbour **2287**. Nothing is orphaned and the gate is green — but **stocking 2286
|
||
would orphan it today**, and the hazard grows with every shop stocked. Carried as risk #6.
|
||
|
||
### One correction to the round doc's framing, for the record
|
||
The brief lists "the manifest" and "sourcing" as if `tier` were superseded. The artifacts say otherwise, and
|
||
**C's ruling is the precise one**: every atlas index carries **both** — `tier: 1` (the ladder rung; a mint
|
||
atlas is *still* a static file, so still rung 1) **and** `sourcing: "real"|"mint"` (where the contents came
|
||
from). They are different axes. The spec documents both, because collapsing them is exactly how a mint crate
|
||
would come to read as real.
|
||
|
||
## Round 25 (2026-07-17) — v5.0-alpha: the sweep reconciled with Ruling 2, six goldens pinned (ledger #1)
|
||
|
||
### → F: **the tree is green — all 8 reds are gone, nothing of mine is pending** (the handshake)
|
||
selfcheck **ALL GREEN 156,211/156,211**; scaffold + consistency green; classic `0x3fa36874` and gig
|
||
`0xb1d48ea1` frozen. The six goldens are pinned: `newtown_godverse` base `0xcf69b387` / gig `0x1e266f60`,
|
||
`redhill_real` base `0x6046700f` / gig `0x673785ee`, `redhill_godverse` base `0xb2224939` / gig
|
||
`0xc6f80e18`. Cross-checked two ways before pinning — **byte-identical across two fresh processes**, and
|
||
the three values G stated independently in `86e2985` match mine exactly. **Your third attempt is unblocked
|
||
from my side.**
|
||
|
||
Two lines still print and **both are deliberate, neither is pending**:
|
||
- `⊘ SKIP real/newtown_godverse: orphaned-atlas check` — correct: the only committed atlas (3962749) keys
|
||
to Red Hill, so newtown has no subject. The vacuous-gate law says say so rather than pass quietly.
|
||
- `⚠ real/redhill_godverse: 1 keyed shop(s) dropped by the lift — 2286 "Empire Revival"` — an honest
|
||
disclosure, not a defect (see below).
|
||
|
||
### My R24 gate asserted Ruling 2 away — that's the whole of my 6 reds, and it was my error
|
||
I built the identity gate on an assumption that was true when I wrote it and false the moment G shipped:
|
||
that a godverse cache is *all* census shops. Ruling 2 makes it **mixed** — a keyed GODVERSE layer plus an
|
||
inherited texture layer — so `newtown_godverse` (18 keyed + 54 texture) and `redhill_godverse` (10 + 27)
|
||
are **healthy towns my gate called broken**. Every keyed assert now runs **per-layer, over the keyed subset
|
||
only**; unkeyed texture shops raise nothing. The validator's "N/M missing" warn is gone with it — the only
|
||
honest requirement left at that layer is that a `godverse+osm` cache have a **non-empty** godverse layer at
|
||
all (else it's an osm cache wearing the wrong `source`), which stays a warn for the R24 charter reasons.
|
||
|
||
### The uniqueness check was broken in both directions — rebuilt so it can actually fail
|
||
The R24 version was `new Set(all ids).size === shops.length`. Every `undefined` collapses into **one** Set
|
||
entry, so it (a) failed structurally on any mixed cache — 54 texture shops became one entry — and (b) could
|
||
**hide a real duplicate behind that same collapse**. It now runs over **defined ids only**. And per the
|
||
vacuous-gate law I made it prove it bites: `validateTownCache` rejects duplicates at the front door, so a
|
||
dup can never reach the plan that way — the test feeds one **straight to the lift** (`generatePlanOSM` does
|
||
not validate) and asserts the predicate catches it, plus a healthy mixed cache passes the same predicate.
|
||
A check nobody can watch fail is not a check.
|
||
|
||
### New — the orphaned-atlas gate (my R24 finding, now enforced instead of just filed)
|
||
The lift legitimately drops shops (overlap-resolve, counted since R19). Dropping a shop that **has a
|
||
committed atlas** means real stock keyed to a shop that isn't in the town — exactly the failure I filed in
|
||
R24, and one F's #7b would only catch at the *end* of a round, in a browser. It's now a node gate: for each
|
||
godverse town, every `stock_godverse/<id>/` on disk whose id belongs to that cache **must be seated**, else
|
||
FAIL. Subject-aware — SKIPs by name when a town has no atlas.
|
||
|
||
**It already found something real, benign today:** redhill's lift drops keyed shop **2286 "Empire Revival"**
|
||
(10 keyed in cache → 9 seated). It has no atlas, so nothing is orphaned and the gate passes. **→ G: if you
|
||
stock Empire Revival at the beta, it will orphan** — the shop isn't on the plan. Monster Robot Party is
|
||
safely seated (lot 8), which is the one that matters this round.
|
||
|
||
## Round 24 (2026-07-17) — v5.0-alpha: the identity field (ledger #2)
|
||
|
||
### → G: **`godverseShopId` is live — emit it, and mind the id-space trap** (the handshake for your #5)
|
||
The field is published and the lift carries it: put `godverseShopId` on shop entries in a
|
||
`source: "godverse+osm"` cache and it lands on `plan.shops[]`, which is what F's per-shop `base` reads.
|
||
Contract in `web/assets/towns/README.md`; `validateTownCache` enforces it; selfcheck gates it.
|
||
|
||
**The round doc says "the POS shop id IS the id — no mapping table". That's true for census shops and
|
||
false for the shop this whole alpha is about** — and your own docstring is what told me:
|
||
|
||
| shop | id source | equals `shop.id`? |
|
||
|---|---|---|
|
||
| census shops (`newtown_godverse`) | thriftgod `shop.id` (max 2992) | yes, incidentally |
|
||
| **Monster Robot Party** | **dealgod store 3962749** — not in thriftgod's census | **no** |
|
||
|
||
So I built it as an **opaque key**, not a census id: no equality assert, no derivation from `id`, no mapping
|
||
table. Both spaces ride it, and your disjointness (2992 vs 3962749) is what makes that safe. **Uniqueness is
|
||
an error**, because two shops keyed to one atlas is mis-stocking (charter risk #3).
|
||
|
||
**⚠ The thing that will bite you in wave 2 — `redhill_godverse` cannot contain Monster Robot from either
|
||
source. Measured, not inferred.** `godverse_town.py` takes **roads from the OSM donor** and **shops from
|
||
thriftgod's census**. The shop is in neither:
|
||
- **Not in the census** — your own R23 finding (that's *why* its id is a dealgod store id).
|
||
- **Not in E's `redhill_real` donor** — I checked the cache the moment it landed: **36 shops, zero of type
|
||
`record`, nothing matching /monster|robot/**. OSM's Red Hill doesn't know the shop exists.
|
||
|
||
So the adapter as written produces a Red Hill of census charity shops, and the atlas at
|
||
`stock_godverse/3962749/` keys to a shop that **isn't in the town** — F's #7b id-equality assertion would
|
||
fail, correctly, at the end of the round. **Inject it explicitly**: name, `type: "record"`, its real lat/lon
|
||
(147 Musgrave Rd), `godverseShopId: 3962749`. The schema is ready for it — that's exactly the case the
|
||
opaque-key design exists to serve.
|
||
|
||
**Second, cheaper trap on the same path:** `redhill_godverse` still has to clear **`MIN_TOWN_SHOPS` (6)**
|
||
from the census bbox alone. Newtown yielded 18; Red Hill is a Brisbane suburb and thriftgod's census is
|
||
thin there. If the bbox returns < 6, the cache is **rejected outright** (hard error, not a warn) and the
|
||
town won't exist. I can't measure that from here — no census DB on m3 — but you'll see it instantly. If it
|
||
bites, the count includes any shop you inject.
|
||
|
||
**E's donor is otherwise good for you:** valid, no warnings, **73.3 m median spacing** — half the warn floor,
|
||
a proper high street to hang the shop on.
|
||
|
||
### → F: the seam you need for #6a
|
||
`plan.shops[].godverseShopId` is the per-shop `base` key — read it off the **plan**, not the cache. It's
|
||
absent (not `undefined`) on every non-godverse shop, so `'godverseShopId' in shop` is a clean tier test:
|
||
present ⇒ tier 1 candidate, absent ⇒ tier 0 parody. That absence is also why **no golden moved this round**.
|
||
|
||
### → Fable: one recorded departure from the ledger, and why
|
||
Ledger #2 says `validateTownCache` **requires** the field on godverse shops. I implemented **missing → warn**
|
||
(malformed and duplicate → hard errors). Three reasons, and it's yours to overrule:
|
||
1. **The charter's determinism boundary** — *world = seeded, frozen, gated; stock = data tiers*. An error
|
||
inverts it: the **town** would fail to load because its **stock** identity is absent. Tier 0 must boot.
|
||
2. **Charter risk #3 names the remedy** — identity gaps "fail-soft to tier 0, never mis-stocked". Missing
|
||
*is* the soft case; duplicate is the hard one. They're different failures and now get different arms.
|
||
3. **Sequencing** — `newtown_godverse` carries 0 ids today, it's G's to re-emit in wave 2, and I can't do
|
||
it here (their namespace; no census DB on m3). A hard error would red the tree for C and E through all
|
||
of wave 1 to say what the warn already says.
|
||
The requirement still has teeth, in the place R23 taught us to put them: selfcheck **fails on partial
|
||
keying** and prints an explicit **SKIP with the count** at zero — it cannot pass vacuously.
|
||
|
||
### Also: E's toowoomba retirement showed my spacing metric is gameable (worth knowing)
|
||
E tried the re-bbox before retiring and found it **fixed my metric while making the town worse** — median NN
|
||
395.7 → 89.4 m ("passes") but hub density 3/12 → 2/12, the pack's worst. My warn is **necessary, not
|
||
sufficient**: it catches scatter, and it can be satisfied by tightening the box around a scatter. D's hub
|
||
test is the complement, and E used both. Retired pins removed from both golden maps (a golden for a deleted
|
||
town can never fire — the vacuous-gate law one layer down). **Ballarat (255 m) is still flagged and still
|
||
E's call this round; if it moves, I pin it.**
|
||
|
||
## 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)
|
||
E's 17 new towns + Lane G's `newtown_godverse` fed through the lift. **All 36 waiting goldens pinned in
|
||
this one commit** (`REAL_TOWN_GOLDENS` + `REAL_TOWN_GIG_GOLDENS` now hold **23 towns each**, zero unpinned).
|
||
selfcheck **ALL GREEN 161,300/161,300**; scaffold + consistency GREEN; cross-process deterministic (pack-xor
|
||
`0xca140954`). classic `0x3fa36874` / gig `0xb1d48ea1` / marched fixtures **frozen**.
|
||
|
||
### The pack absorbed — everything built this epoch held at 23-town scale, first try
|
||
| | |
|
||
|---|---|
|
||
| towns | **23** (22 real + `newtown_godverse`) |
|
||
| shops | 1,210 → **1,133 seated** (77 dropped, **6.4%**, all counted by cause) |
|
||
| graph | **1 connected component on every town** (junction protection + cull/bridge) |
|
||
| district | **3 venues on every town** (cluster bias + facade clearance) |
|
||
|
||
**fitzroy (160 shops — 2× anything absorbed before)**: 139 seated, 1 component, 3 venues, 212 posters, all
|
||
invariants green. The junction pipeline, fragmentation ruling, cluster bias, poster cap and capacity fence
|
||
all ran at mecca scale with **no new hardening needed** — the pack needed absorbing, not fixing.
|
||
|
||
### One refinement this round: a graded cluster fallback for genuinely thin towns
|
||
At pack scale three towns have **no candidate at the floor at all** — `ballarat`, `toowoomba`,
|
||
`newtown_godverse` top out at **2** shops within 160 m. R21's bias switched off entirely there, stranding
|
||
venues at 0. It now falls back to the **densest available**, so the venue still lands on what little crowd
|
||
the town has: ballarat **0,2,0 → 2,2,2**, godverse **0,1,1 → 2,2,2**, toowoomba **1,0,0 → 1,2,1**.
|
||
It only fires where the floor is unreachable ⇒ **no already-pinned town moved** (verified).
|
||
|
||
**Acceptance (R21's, at 23-town scale): 20 of 23 towns have every venue ≥3 shops/160 m.** The three above
|
||
sit at their town's genuine ceiling — the "no better candidate exists" case the acceptance carved out.
|
||
**Saying so explicitly, as asked.**
|
||
|
||
### Drop outliers (counted, not hidden)
|
||
`daylesford` **19%** (6/32, all overflow — 32 shops on a 171-road graph, the thinnest graph in the pack) and
|
||
`fitzroy` **13%** (21/160, 17 overflow). Both are the R20 capacity fence doing its job: `RGAP` is already
|
||
0.5 m and `NODE_CLEAR` 3 m, so the only lever left is spilling overflow onto a *different* street — which
|
||
buys a few percent by putting a shop on a road it isn't on. Same call as R20: **drop and count beats
|
||
teleport**. Everything ≤6.4% pack-wide.
|
||
|
||
### → B / F (the tri diet, ledger #1): my side of the diagnosis
|
||
My poster cap scales off **shop count**, so it auto-tracked the widening rather than fighting it (fitzroy
|
||
160 shops → 212 posters; katoomba 80 → 111). If B's layer-by-layer names **plan geometry** (lot or poster
|
||
counts) as the `venue_night` driver, I own the fix and will take it. My read from the plan side: the growth
|
||
is *more shops ⇒ more night building geometry at the venue block* — E's density landing as B's render cost —
|
||
so I'd expect the driver to be buildings/LOD rather than posters. B's numbers decide it, not my expectation.
|
||
|
||
**CLOSED — B's diagnosis landed (`0c4dad5`): the fix is not A's.** The driver is E's four furniture GLBs at
|
||
**149,161 of 163,015 tris (91.5%)** ⇒ **asset tris ⇒ Lane E owns it** by the ledger's own rule. Plan geometry
|
||
is nowhere near the bill: **posters 222 tris** (B: the cap "already worked and was never the lever"),
|
||
**buildings 348** (the R3 atlas/instancing holding), venue pools 0. **A is clear on ledger #1 — F, don't wait
|
||
on me for the re-measure.** Worth recording that B's numbers corrected my expectation too: I guessed
|
||
buildings/LOD, and B measured it at 0.2%. B also found the real coupling — **shop count doesn't drive this,
|
||
ROAD density does** (the bench/bin/shelter cadence runs on a 966-edge graph), which means my absorb of E's
|
||
widening was never the cause either. The brief is a hypothesis; the measurement wins — mine included.
|
||
|
||
## 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)
|
||
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).
|
||
|
||
### → all: **densities absorbed, goldens pinned** (ledger #2 — E's widened caches landed)
|
||
E's five caches at **3–6× shops** (katoomba 20→80, fremantle 21→80, newtown 18→72, bendigo 9→36,
|
||
castlemaine 6→24 = 292 total) are absorbed. **All five place 3 venues**; the poster cap auto-scaled off the
|
||
live shop count exactly as designed (katoomba 111 posters at 80 shops). selfcheck **ALL GREEN 51866/51866**.
|
||
- **Capacity widened** (the R18 overflow fence, load-tested). At 4× the dominant drop cause was **overflow**
|
||
(an edge-side holds more shops than fit) — katoomba 10 of 11 drops. Tightened the run: `NODE_CLEAR` 6→3
|
||
and a **roads-only `RGAP` 2→0.5** (the shared marched `GAP` is untouched ⇒ marched fixtures stay frozen).
|
||
Drops **7.9% → 5.5%** (16/292). 0.5 m also reads truer — a real AU main street is continuous terrace
|
||
shopfronts. Residual drops stay **counted, now by cause** (`dropped_overflow` / `_overlap` / `_stranded`).
|
||
I stopped there deliberately: the next lever is spilling overflow onto a *different* street, which buys
|
||
~1% at the cost of putting a shop on a road it isn't on. Dropping a crowded shop is the honest trade.
|
||
- **Venue facade bias now covers `pub`.** R16 exempted pub ("spine-fronted, +Z always open") — true on
|
||
synthetic/marched, but on a REAL graph at density the pub landed where its frontage poster overhung a
|
||
cross street (newtown, −0.91 m). The `POSTER_CLEAR` filter now covers every venue kind; still a **no-op on
|
||
synthetic** (every pub candidate already clears) so the gig golden `0xb1d48ea1` is frozen.
|
||
- **Goldens re-pinned once** (base + gig, all five): katoomba `0x420f677d` / `0x2115732a`, newtown
|
||
`0x741c18ec` / `0x17ab31bb`, fremantle `0x73d385df` / `0x82c27397`, bendigo `0xc175194e` / `0xf5ce58c4`,
|
||
castlemaine `0x26c128c9` / `0x48272da9`. classic/gig/marched fixtures **frozen**.
|
||
- **Fixed:** my real-cache loader globbed `*.json` and tried to validate E's new `index.json` as a town
|
||
cache. It now skips it (it's B's selector index, not a cache).
|
||
|
||
## 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)
|
||
The A→E→A finalization landed. `katoomba_real` golden = **`0xb7ffbca2`** (pinned); the other four real towns
|
||
pinned too (newtown `0x6f984c81`, fremantle `0xfb197000`, bendigo `0x21179b3b`, castlemaine `0x7c47f639`).
|
||
selfcheck **ALL GREEN 51670/51670**; classic/gig/marched-fixtures frozen (`0x3fa36874` / `0xb1d48ea1` / …).
|
||
|
||
### The fragmentation fix (D's finding — Fable's ruling, my details)
|
||
**katoomba: 105 components → 1; 58% → 100% of street-metres in the main net.** Two mechanisms:
|
||
1. **Junction-protected Douglas–Peucker** (the root cause). A point shared by ≥2 ways is a junction;
|
||
simplify only *between* junctions, never through one — the collinear junction-drop where a side street
|
||
tees into a straight main road. This alone took every town to 94–100% main-net.
|
||
2. **Cull/bridge** (`JOIN_TOL` 12 m, shop-island bridge ≤ 200 m): main component + joined near-fragments;
|
||
far **shopless** islands culled (bbox-clipped noise + dead streaming budget = half of B's tris problem).
|
||
Per-town: katoomba 2 islands culled/0 bridges, newtown 3/4, fremantle 4/0, bendigo 3/1, castlemaine 0/1.
|
||
**Every dropped shop is counted** in `norm.dropped` (ruling): katoomba kept 19/20 (1 overflow/overlap
|
||
crowd drop — the R18 overflow prediction, now measured + counted), the rest kept all.
|
||
|
||
### → D: your re-measure inputs (post-fix)
|
||
Component count → **1** (all towns). Street-metres in main net → **100%**. Stranded shops → **0** (all
|
||
connected or counted-dropped). Near-crowd on fragments should collapse to ~0 (one component). The crafted
|
||
irregular-town scaffold still runs; the real katoomba golden is pinned so your reruns are stable.
|
||
|
||
### → F: E's poster finding resolved
|
||
Spine posters that overhang a **real crossing carriageway** are now skipped (measured exactly as the
|
||
selfcheck kerb floor). No-op on synthetic (gig golden frozen); the 23 real-town clearance failures → 0.
|
||
|
||
### New finding (v4.0-beta, non-blocking): spine-poster density scales with edges
|
||
Real katoomba emits **438 posters** (2 per main edge × a dense real graph) vs ~14 synthetic. Draws stay low
|
||
(instanced per gigId, B measured ~1 draw), and every poster clears its kerb — but 438 pole-posters town-wide
|
||
reads busy. **Beta candidate:** cap posters-per-town (seeded subsample of the spine run). Not fixed this
|
||
round — invariants hold, it's density polish.
|
||
|
||
## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 (roads[]) + the real-graph lift
|
||
|
||
### → E: **schema v2 is live (committed first) — fetch roads to it**
|
||
A v2 cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` (`kind` = OSM `highway=*`). Full
|
||
contract in `web/assets/towns/README.md`; `validateTownCache` enforces it (schema `…/1` still valid →
|
||
marched). **`katoomba_real` first** (the alpha gate). Bound the `highway=*` query to the same per-town bbox
|
||
as the shops. Snap tolerance is 3 m, so ship intersections as **shared coordinates** where ways meet (OSM
|
||
already does) — that's how the lift finds real intersections. Drop each cache in; the selfcheck auto-builds
|
||
+ validates the real graph and prints the golden. Ping me → I pin `katoomba_real`'s real-roads golden.
|
||
|
||
### The graph lift (`plan_osm.buildRealRoads`) — implemented + proven on a synthetic irregular graph
|
||
Real OSM ways → the CityPlan street graph, **same schema out** (B/C/D/F unchanged by contract). Pipeline:
|
||
project → simplify (Douglas–Peucker ε≈6 m) → snap coincident points (≤3 m) to shared nodes = real
|
||
intersections → straight-segment edges → classify `main` by OSM class + shop density → seat each shop on
|
||
its **nearest real edge** (real order, real side), marched, overlap-resolved. Passes the **full
|
||
structuralSuite + gig district + determinism** on a synthetic town with a main street, two cross streets
|
||
(a real intersection + an acute angle), a dead-end lane and a curve. `roads` absent ⇒ the v1 marched
|
||
fallback — **all osm goldens frozen** (melbourne/katoomba/silverton), classic/gig frozen. NOT YET run
|
||
against real katoomba geometry — that's the A→E→A finalization (E fetches, I pin + harden).
|
||
|
||
### Fidelity decision (charter risk #4 — A owns the knob) + the alpha failure list
|
||
I chose **real edge + real order + real side, regularised spacing** — NOT pixel-exact shop positions,
|
||
because real shops overlap and the no-overlap invariant is load-bearing (venues, chunk streaming, sim all
|
||
assume it). Written down for the alpha (fixed or fenced):
|
||
1. **Overflow drop.** A short real edge can't seat all its shops (marched at footprint+gap); overflow shops
|
||
drop (real order kept). `norm.dropped` counts them. Katoomba (20 shops, real street lengths) should keep
|
||
all — verify when E's roads land. *Fence if it bites: widen edge capacity or split long shop runs.*
|
||
2. **Intersection topology = shared coordinates only.** Two ways that cross WITHOUT a shared OSM node
|
||
become topologically-disconnected edges (visually crossing, not graph-connected). **→ D:** the sim graph
|
||
may not route across such crossings; if Katoomba has any, that's the risk to measure. E ships shared
|
||
coords at junctions (OSM convention) so most are fine.
|
||
3. **Blocks are per-edge frontage strips**, not planar faces. **→ B:** your ground/pavement render reads
|
||
these polys; a per-edge strip may look thin/overlapping at a tight corner — audit + file with a
|
||
measurement (it's my lot geometry to fix, not yours).
|
||
4. **Overlap-resolve drops crowded-intersection lots** (deterministic, by shop id). Rare; counted in
|
||
`norm.dropped`.
|
||
|
||
### → B/C/D: handshake state
|
||
`katoomba_real` still MARCHES today (it's v1 — E hasn't fetched roads yet), so I can't yet say "boots with
|
||
real roads, golden pinned." I'll fire that handshake the moment E's katoomba roads land and I pin the
|
||
golden. Per Fable's note, your audit prep (scaffolding, assertion lists) isn't blocked on me — the graph
|
||
lift + the failure list above tell you exactly what irregular geometry to point your audits at.
|
||
|
||
## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data
|
||
|
||
### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake)
|
||
Your pipeline outputs one JSON per real town in the processed shape; `plan_osm` marches it like the
|
||
fixtures. **Home: `web/assets/towns/<key>.json`** (full spec in that dir's `README.md`). Shape:
|
||
```
|
||
{ schema:"procity-town-cache/1", key, town, source:"osm", license, attribution,
|
||
center:{lat,lon}, shops:[ {id, name, type, lat, lon, suburb?} ] } // + optional bbox/counts/fetchedAt
|
||
```
|
||
- **Authoritative validator:** `validateTownCache(cache)` in `plan_osm.js` — `{ok, errors[], warnings[]}`.
|
||
Import it; don't hand-roll the check. **Hard errors:** finite `center.{lat,lon}`, `shops` ≥ **6**
|
||
(`MIN_TOWN_SHOPS`), finite shop `lat`/`lon`. **Warnings (absorbed):** blank name → type label, unknown
|
||
`type` → `opshop`, dup ids, **span > 5 km**.
|
||
- `type` should be a registry `SHOP_TYPE`; map OSM `shop=*` tags to those (unknowns → `opshop`).
|
||
- **Katoomba-real:** use a DISTINCT key (`katoomba_real`, not `katoomba`) so it sits alongside the
|
||
hand-made fixture for the comparison — `osmTownKeys()` dedupes but same-key would shadow the fixture.
|
||
- Drop each cache in and the selfcheck auto-validates + boots + prints its golden to pin; ping me
|
||
(E→A handshake) and I'll pin `REAL_TOWN_GOLDENS` + it enters the sweep.
|
||
- **ODbL:** ship `SOURCES.md` + the `license`/`attribution` fields; cache raw Overpass so re-runs don't refetch.
|
||
|
||
### plan_osm hardening — verified against real data (2,828 real Canberra shops)
|
||
I ran thriftgod's real `city_source.json` through the hardened `plan_osm`: **it boots a valid plan** (0
|
||
bad-coord lots, 0 "undefined" names). The marching regularises messy real coords, so dup/odd positions are
|
||
a non-issue. Added: blank-name → registry-label default; the contract validator gates the rest.
|
||
|
||
### → Fable / v4 charter — the ugly-real-geometry RISK LIST (the scout's real finding)
|
||
1. **The mega-strip (the big one).** An unbounded Overpass bbox = a whole region. All 2,828 Canberra shops
|
||
marched into an **11.6 km** single strip (350 shops/avenue) — a valid plan, an unusable "town". **Mitigation
|
||
shipped:** `validateTownCache` warns at span > 5 km; the contract says a cache is ONE compact town. **v4
|
||
risk:** town SELECTION (bbox per town) is E's pipeline's job and the real quality lever — the generator
|
||
can't rescue an over-broad query. Charter needs a per-town bbox/centre discipline.
|
||
2. **Sparse compact towns.** A tight 2 km radius of inner Canberra held only 6 secondhand shops — real small
|
||
towns can fall below a usable density. `MIN_TOWN_SHOPS` is 6 (the functional floor for the district); E
|
||
should curate towns with real shopping density (Katoomba 19, Melbourne 95 clear it easily).
|
||
3. **Not a risk (settled):** osm facades face `−Z`/south with rows a fixed 54 m apart, so the R16 `+Z`
|
||
clearance bias is a no-op on osm towns (facades always clear) — real towns won't trip the frontage floor.
|
||
|
||
### Scope held: no new plan features, no golden moved
|
||
Classic `0x3fa36874`, gig `0xb1d48ea1`, all three osm fixtures frozen. selfcheck **15277/15277** (added the
|
||
contract stress suite + the ready-and-empty real-cache path). The scout proved the *existing* contract eats
|
||
real data — nothing more, per the fence.
|
||
|
||
## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7)
|
||
|
||
### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged)
|
||
The round's one sanctioned move. **F**: the default-boot gate pins to `0xb1d48ea1` (not the R15 value the
|
||
brief cited). **B**: re-shoot against this. Amendment law; §v3 marker records it.
|
||
|
||
### The corner-poster fix (NOT a nudge — Fable's brief premise was wrong)
|
||
The R15 finding was that a corner venue's frontage poster sits near a crossing kerb. The brief proposed
|
||
nudging the poster ALONG the facade away from the crossing-street *end* — but I proved that's geometrically
|
||
impossible: the crossing edge is **parallel** to the facade and spans it (0/187 along-facade slides recover
|
||
any clearance). Root cause: band_room/rsl land on **through-lots on narrow warehouse-fringe blocks**, so the
|
||
`+Z` wall (B's door side, where the poster seats) faces the block's *other* street, overlapping its
|
||
carriageway by up to ~3 m. **Fable's ruling (asked mid-round): bias `pickVenues`.** So:
|
||
- `gigs.js pickVenues` now prefers a band_room/rsl candidate whose `+Z` facade clears every carriageway by
|
||
**≥ `POSTER_CLEAR` (0.5 m)**; if the warehouse fringe is uniformly narrow it broadens to any off-spine
|
||
clear lot before falling back. Proven: **0 of 851** frontage posters across 400 seeds now sit below the
|
||
floor (worst clearance 0.51 m). Pub is untouched (spine-fronted, `+Z` open — the hero shot doesn't move).
|
||
- The R15 blanket frontage *exemption* in the selfcheck is now a real **floor**: frontage posters must clear
|
||
their kerb by ≥ `POSTER_CLEAR`; spine posters keep the ≥ 0 no-bitumen floor. `POSTER_CLEAR` is exported
|
||
from the barrel — import it, don't re-guess. (`−0.02` in the assert absorbs r2 poster-coord rounding.)
|
||
|
||
### The no-spine osm fixture (ledger #7): `silverton`
|
||
`osm_fixture.js` gains **`silverton`** — a *constructed* single-row town (all shops at one latitude ⇒
|
||
plan_osm bins them into one avenue ⇒ **0 `main` edges**). Closes the R14 sweep gap (both real fixtures had a
|
||
spine): it proves `pickVenues`' no-spine branch (pub's `onMain` filter is empty) and `buildPosters`' no-main
|
||
fallback (spine run over every edge). Base golden pinned `0xd4b351c9`; the sweep asserts 0 main edges +
|
||
graceful 2–4-venue placement. `osmTownKeys()` now returns 3 towns — anything iterating it (shell selector,
|
||
your gates) picks it up; it's a valid town, `?plansrc=osm&town=silverton`.
|
||
|
||
### → F: the classic-law / flags-table §v3 amendment is yours
|
||
I amended §v3 for the golden + poster bias only (localized, recorded in the FROZEN marker). The v3.1 flip
|
||
(default-on, `?classic=1` covenant, the flags table) is your §v3 amendment — I left the prime-flag-law
|
||
blockquote untouched to avoid racing you on the same lines. My classic golden (`0x3fa36874`) is your
|
||
`?classic=1` regression target; my gig golden (`0xb1d48ea1`) is your default-boot target.
|
||
|
||
## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + the one sanctioned golden move
|
||
|
||
### → B + F: **new gig golden `0x1f636349 → 0x4f4a549d`** (synthetic `0x3fa36874` unchanged)
|
||
The frontage-poster fix moved `plan.posters`, so the gig golden re-pinned — the round's ONE sanctioned
|
||
move (amendment law; §v3 marker records it). B: re-shoot `queue_night` against this. F: the smoke's gig
|
||
golden is now `0x4f4a549d`.
|
||
|
||
### The fix (gigs.js buildPosters item 1)
|
||
Each venue's own-frontage poster sat at local **−Z** — the *back* of the building (~16 m behind, matching
|
||
B's 15.9 m), invisible from the street, which is why `queue_night` framed no poster. Flipped to the **+Z
|
||
street facade** AND turned it to read from the street: `ry = lot.ry + π` so its printed face points `+Z`
|
||
at the queue-side camera (else `DoubleSide` shows a mirrored band name). Proven: pub + rsl frontage posters
|
||
now sit **0.06 m from B's door**, printed-face·(+Z street) = **1.000**. Only item 1; spine posters untouched.
|
||
|
||
### The −Z/+Z convention, settled (worth recording — it's the R13 RY_FLIP π-ambiguity again)
|
||
The **visible** venue face is `+Z`: `buildings.js` builds the facade/door at `zf = +d/2` ("front face at
|
||
local +Z"), and `venue.js` seats the door-glow/queue/poster-camera on `(sin ry, cos ry)` = `+Z`. The
|
||
selfcheck's `facing = (−sin ry,−cos ry)` "every lot faces its frontEdge" is the **sim/GLB** convention,
|
||
180° off from the visual front (same ambiguity D fixed at the source for the fleet in R13). A poster faces
|
||
the *viewer* when its printed face = `(−sin ry,−cos ry)` points at them, i.e. `ry = atan2(away-from-viewer)`.
|
||
|
||
### → B + F (eyeball in the tour): corner-venue frontage posters can sit near a crossing street
|
||
The `+Z` facade is the block-interior side in *plan* coordinates, so for a **corner venue** (a band_room/
|
||
rsl fronting one street with another behind — e.g. seed 1 band_room lot#481, 1.58 m from a side-street
|
||
centreline) the frontage poster lands near that crossing street's kerb. That's B's building geometry (the
|
||
door is there too), not a mid-road defect — so the selfcheck's no-bitumen assert now **exempts frontage
|
||
posters** (they're building-bound) and keeps it for free-standing **spine** posters. The pub hero is clean
|
||
(29.9 m of open ground on `+Z`). If a corner poster reads as floating over a road in the tour, that's a
|
||
v3.1 venue-placement tweak, not a v3.0 blocker.
|
||
|
||
## Round 14 (2026-07-16) — v3.0 RELEASE: invariant sweep + CITY_SPEC §v3 frozen
|
||
|
||
Release round — no new systems, no golden moves. **Synthetic `0x3fa36874` and gig `0x1f636349` are
|
||
unchanged**; the only code I touched is `selfcheck.js` (test-only), so no seeded stream gained a draw.
|
||
|
||
### The 400-seed district sweep (`selfcheck.js`, ALL GREEN 14264/14264 in ~0.7s)
|
||
- Factored the district-contract checks out of `gigSuite` into `districtInvariants(plan, label)` so the
|
||
sweep asserts them at SCALE without re-running the heavy `structuralSuite` (that still runs on the 6
|
||
hero `SEEDS` + osm parity). Behaviour-preserving — the 4 hero-seed `gigSuite` runs are byte-identical.
|
||
- Sweeps **seeds 1–400** synthetic + the large edge seeds (2e9, 8675309) asserting the full district
|
||
contract each: 2–4 venues, ≥1 pub, never the openLate shop, no band twice in one night town-wide,
|
||
per-venue 4–6 nights, every poster off the carriageway. Also asserts the seeded count **spans {2,3,4}**
|
||
across the range (measured 146/121/133) — proves the 2–4 range is genuinely exercised, not pinned.
|
||
- **osm graceful placement**: `katoomba` (smallest fixture, 19 shops, **no warehouse district**) still
|
||
lands 2–4 valid venues incl. a pub for every hero seed — proving `pickVenues`' off-spine fallback.
|
||
Honest scope: both osm fixtures keep a main spine, so this proves the *no-warehouse* branch, not
|
||
*no-spine* (a genuine no-spine town would need a single-row fixture that doesn't exist yet — v3.1).
|
||
- Poster check kept as "≥ `roadWidth/2` from the nearest edge centreline" (not a literal `poleOffset`
|
||
compare, which would false-fail legit corner posters seated behind their placement edge's `poleOffset`
|
||
but inside a crossing edge's clearance band). Added a `−1e-6` epsilon on the strict `<` to immunise the
|
||
exact kerb-line boundary against r2 rounding (only reachable on a no-main-spine fallback; not hit today).
|
||
|
||
### CITY_SPEC §v3 frozen at v3.0
|
||
Final wording pass; marked **FROZEN at `v3.0` (round 14)** with a §v2-style marker. Venues table, genre
|
||
map, gigKey contract, corridor law, and gig golden `0x1f636349` are all final (genres are pubrock/grunge/
|
||
covers — Fable's R14 ruling, John did not re-flavour).
|
||
|
||
### → Lane F: the alias-retirement handshake (R13 debt #1) — I deliberately did NOT touch it
|
||
The R12 single-venue compat alias is **still live** (`gig_state.js:141-151`, read by `audio.js`/`venue.js`),
|
||
so I left the §v3 alias sentences (`CITY_SPEC.md` ~lines 271–272) **intact** — deleting them at freeze
|
||
time would be doc-ahead-of-code and would race you on the same lines. The freeze marker explicitly carves
|
||
this out as your pending edit. In your alias-retirement commit (after B drops its readers), delete both
|
||
`gig_state.js:141-151` and those two spec sentences **together** — one atomic step, one deleter, no race.
|
||
- While you're in that runtime block: it enumerates `stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf`
|
||
+ `markPaidOf` + `.venueShopIds`, but **omits `nightOf(id)`** (`gig_state.js:131`). Add it when you edit.
|
||
|
||
### → Lane B: second edit site for the bare-string arm
|
||
When you remove the `venue.update()` bare-string alias arm, note `venue.js:228` also self-calls
|
||
`update('quiet')` at construction through that same arm — fix both or it falls through (harmless today:
|
||
still yields `'quiet'`, but it's a live second site).
|
||
|
||
## Round 13 (2026-07-15) — v3.0-beta THE DISTRICT (published FIRST; C/D/B/F hang off this)
|
||
|
||
Supersedes the R12 alpha interface below. Same flag (`?gigs=1`), same prime-flag law: flag OFF ⇒ plan
|
||
byte-identical to v2 (synthetic golden still `0x3fa36874`, osm unchanged). What changed: **one venue → 2–4;
|
||
one genre → three (by kind); one night's posters → town-wide.** The gig **entry shapes are unchanged** —
|
||
downstream code that read one venue now iterates many. Verified green: `selfcheck.js` 3273/3273,
|
||
`scaffold_check` + `consistency` GREEN.
|
||
|
||
**Gig golden re-pinned: `0xa6ae5a5e` → `0x1f636349`** (the district changed gig-plan output; re-pinned in
|
||
the same commit as the change per CITY_SPEC amendment law). Turn-on is unchanged:
|
||
`generatePlanFor(seed, src, { town, gigs:true, customBands })`.
|
||
|
||
### The venues (NEW: 2–4 per town, three kinds) — `plan.shops.filter(s => s.venue)`
|
||
Each converted-in-place venue shop carries three new fields:
|
||
```
|
||
s.venue === true · s.venueKind ∈ {'pub','band_room','rsl'} · s.genreKey ∈ {'pubrock','grunge','covers'}
|
||
s.type === s.venueKind // C keys its interior recipe off shop.type — the type IS the kind
|
||
```
|
||
| kind | genreKey | placement | hours | reads as |
|
||
|---|---|---|---|---|
|
||
| `pub` | `pubrock` | spine end/corner | `[17,23]` | ≥2-storey corner hotel |
|
||
| `band_room` | `grunge` | warehouse fringe | `[18,23]` | single-storey tin shed |
|
||
| `rsl` | `covers` | off-spine | `[16,23]` | carpet-and-bistro club |
|
||
- Seeded count 2–4; a 4th venue is a **second pub** at the far spine end. A town always has ≥1 pub.
|
||
- **Never** the openLate landmark (still exactly one openLate shop). Placement degrades gracefully so osm
|
||
towns (no warehouse district, maybe no spine) still get their venues.
|
||
- `genreKey` comes from the registry kind→genre map: `genreForVenueKind(kind)` / `SHOP_TYPES[kind].genre`.
|
||
|
||
### THE gigKey CONTRACT (debt #1 — import it, don't build the string)
|
||
The audio-manifest bed key **IS** the gigKey and is **always** `gig-<genreKey>`. No mapping table anywhere:
|
||
```js
|
||
import { gigKeyFor } from './citygen/index.js'; // re-exported from the barrel
|
||
gigKeyFor('pubrock') === 'gig-pubrock' // C sets audio.gigKey = gigKeyFor(genreKey); E names the bed the same; B reads it; F resolves it
|
||
```
|
||
R12 lost a bed to a private rename (`gig-pubrock` vs `pubrock-live`) — audio fails soft, so the band just
|
||
mimed. One key, zero mapping. E renames `pubrock-live → gig-pubrock` this round; F deletes the `GIG_BED` bridge.
|
||
|
||
### `plan.gigs` — the week across the whole district (entry shape UNCHANGED)
|
||
```
|
||
plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ]
|
||
```
|
||
- **7 nights × every venue, but not every venue plays every night**: each venue plays a seeded **4–6** of
|
||
the 7, dark the rest (reads true). `gigId` unique **town-wide**; `night ∈ [0,7)`; `genreKey ≡` its venue's.
|
||
- **Pick tonight per venue** (each venue has its own state now): `plan.gigs.filter(g => g.venueShopId === v.id && g.night === 0)[0]` — **may be undefined** (venue dark tonight; F/B skip it, no poster).
|
||
- `startSeg=5 endSeg=5`. Same `quiet → doors(seg4) → on(seg5) → done` machine — F runs one **per venue**.
|
||
- `cover` ∈ `{0}∪[2,10]`, skewed by kind: pub 50% free/`$2–10`, RSL 45% free/`$2–5`, band_room 70% free/`$2–6`.
|
||
F debits **per venue** — a night at two venues is two covers.
|
||
- **No band plays two venues on one night** (guaranteed by A). Custom names spread night-major across the
|
||
whole week town-wide (deduped, priority-seeded), the rest generated.
|
||
|
||
### `plan.posters` — town-wide now (shape UNCHANGED; **Lane B** renders)
|
||
```
|
||
plan.posters = [ { id, gigId, x, z, ry }, … ] // only TONIGHT's (night 0) playing venues advertised
|
||
```
|
||
- One on each playing venue's **own frontage** (on the facade plane) + a seeded spine run (2 per main edge).
|
||
- Different venues' posters share the same pole runs — that IS the district reading. `p.gigId` tells B/F
|
||
which venue's gig each poster sells (`plan.gigs.find(g=>g.gigId===p.gigId)`).
|
||
- **Debt #5 fixed:** posters no longer land mid-road. See the corridor law ↓.
|
||
|
||
### The street-corridor law (debt #5) — NEW helpers, use for ANY street furniture
|
||
`edge.width` is the **full corridor** (carriageway + verge, centreline ± width/2), NOT the carriageway.
|
||
`node.x + 2` seats furniture on the bitumen. Registry now exports the split (re-exported from the barrel):
|
||
```js
|
||
import { roadWidth, vergeBand, poleOffset } from './citygen/index.js';
|
||
roadWidth(edge) // carriageway half-corridor (main 10m, side 6–8, lane=all, arcade 0)
|
||
vergeBand(edge) // [inner,outer] — where footpath furniture belongs
|
||
poleOffset(edge) // where a pole/poster/post seats: just behind the kerb
|
||
```
|
||
Selfcheck now **asserts** no poster stands within `roadWidth/2` of any edge centreline.
|
||
|
||
### → Lane C: two recipes to add + the gigKey + a corrected field
|
||
- `getRecipe(shop.type)` resolves off `RECIPES[type]` — `RECIPES.pub` exists; **`RECIPES.band_room` and
|
||
`RECIPES.rsl` do NOT yet.** Until you add them, those venues fall back to the op-shop interior. That's
|
||
the handshake — you're next in order.
|
||
- Set `room.audio.gigKey = gigKeyFor(shop.genreKey)` (import from the barrel) — never hand-build `'gig-…'`.
|
||
- Registry `pub.interior` was `'band_room'` (harmless, unread — you key off `shop.type` via `canonicalType`);
|
||
I corrected it to `'pub'` so it isn't a name-collision with the new `band_room` venue type. No behaviour change.
|
||
|
||
### → Lane D: iterate the venues, one setGig each
|
||
`const venues = plan.shops.filter(s => s.venue)` — call `setGig` **per venue** (multiple concurrent). Tonight's
|
||
gig per venue as above (skip dark ones). `isOpen(shopOrHours, hour)` unchanged (half-open law). Queue zone
|
||
comes from B (`venue.queueZone`) — consume if present, else a straight line off the door.
|
||
|
||
### → Lane F: state machine goes per-venue
|
||
One latch **per venue keyed by `venueShopId`**, off the same `procity:segment` spine. `gigKeyFor(genreKey)`
|
||
resolves all three genres (the debt-#1 assert now covers 3). Delete `GIG_BED` once E's rename lands. Cover
|
||
charge is per venue (per-venue paid stamps). Keep the alpha `window.PROCITY.gigs` shape as a compat alias
|
||
for one round (B reads it mid-round).
|
||
|
||
## Round 12 (2026-07-15) — v3.0-alpha GIG INTERFACE (published; C/D/B/F build off this)
|
||
|
||
Everything is behind `?gigs=1` (prime flag law): with the flag OFF the plan is **byte-identical** to
|
||
v2 (all goldens frozen — synthetic `0x3fa36874`, osm unchanged). The gig layer is a post-hoc
|
||
augmentation applied by the selector, so it works on synthetic AND osm towns.
|
||
|
||
### How the gig layer turns on (Lane F wires this)
|
||
```js
|
||
// F's shell bootstrap, when ?gigs=1:
|
||
const customBands = await loadCustomBands(); // see drop-in below; [] under ?noassets or if absent
|
||
const plan = citygen.generatePlanFor(seed, src, { town, gigs: true, customBands });
|
||
```
|
||
`generatePlanFor(seed, source, { gigs:true, customBands })` → base plan + `withGigs`. Without
|
||
`gigs:true`, the base plan is returned untouched. `generatePlanFor` stays **synchronous** — F does the
|
||
async `custom_bands.json` fetch in the bootstrap and passes the array in.
|
||
|
||
### `plan.gigs` — the nightly schedule (NEW, present only when gigs on)
|
||
```
|
||
plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ]
|
||
```
|
||
- 7 nightly entries at the one venue. `night` = 0-based index; **F picks tonight's** (alpha: `gigs[0]`).
|
||
- `venueShopId` = the pub shop's id. `genreKey` = `'pubrock'` (alpha's one genre; E ships that bed).
|
||
- `startSeg=5` (NIGHT, 22:00) `endSeg=5`. F's state machine: `quiet → doors (startSeg−1 = DUSK seg4)
|
||
→ on (seg5) → done`. Segment→hour map is lighting.js (`0 DAWN … 5 NIGHT`).
|
||
- `cover`: integer. **~half are 0 (free, walk in); the rest $2–$10** (F debits the wallet at the door).
|
||
|
||
### The venue (one `pub` per town)
|
||
- A spine-END plain shop is converted in place to `type:'pub'` (keeps its lot id + geometry ⇒ no
|
||
overlap, enterable like any shop). Tagged **`shop.venue === true`**; also `plan.shops.find(s=>s.type==='pub')`.
|
||
- **Never** the openLate landmark (the video survives — the town still has exactly one openLate shop).
|
||
- Registry `pub` type added: facades, `interior:'band_room'` (→ **Lane C** builds the pub archetype off
|
||
this), fittings hints, hours `[17,23]`. It has **no district weights** so it's never auto-placed.
|
||
- Venue hours `[17,23]` are open through DUSK+NIGHT so the gig runs and **D's patronage surge** fills it.
|
||
|
||
### `plan.posters` — gig advertising (NEW; **Lane B** renders)
|
||
```
|
||
plan.posters = [ { id, gigId, x, z, ry }, … ] // ~6, advertising tonight's gig (gigs[0])
|
||
```
|
||
One on the venue frontage + a seeded few along the spine, on the poles/walls B already streams. B
|
||
places E's poster skin at `(x,z)` rotated `ry`. F wires "poster → tonight's-gig discoverability".
|
||
|
||
### → Lane D: the closing-time debt is PAID — consume `isOpen`
|
||
`citygen.isOpen(shopOrHours, hour)` is the canonical **half-open** law (`open ≤ hour < close`) — the
|
||
frozen closing-time ruling as code. Use it for the gig-night patronage surge: the venue is open through
|
||
the gig, patrons enter while open, and at close they **drain** (finish their dwell) — never popped, and
|
||
the player is never force-ejected. Your `sim.js _openAt` already matches; switch to `isOpen` so there's
|
||
one source of truth (or keep yours — they're identical).
|
||
|
||
### → John: the band-name drop-in (`web/assets/custom_bands.json`)
|
||
Edit the file, reload — your names get **priority** on the town's posters. Schema:
|
||
`{ "bands": ["The Feral Galahs", …] }`. **Delete it and the generator takes over** (no errors, no fetch
|
||
under `?noassets=1`). Deterministic given (seed, file contents). An example ships with 10 names; a town
|
||
has 7 nightly slots, so up to 7 of yours surface (shuffled by seed).
|
||
|
||
## Round 9 (2026-07-15)
|
||
|
||
### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it)
|
||
|
||
Question: what happens to a ped browsing inside a shop when it hits closing time? **Ruling** (now a
|
||
frozen contract law — CITY_SPEC §"CityPlan v2 → Closing-time occupant ruling"):
|
||
1. **No new entry when closed.** Openness is half-open: `open ≤ h < close` (a shop closing at 17 is shut
|
||
at 17:00). Patronage only routes into an *open* shop; the player gets the CLOSED plate. ✓ your
|
||
`sim.js _openAt` + `_nearestOpenShop` already do exactly this.
|
||
2. **Occupants drain, they don't pop.** A ped inside at close finishes its bounded 5–20s dwell and
|
||
re-emerges normally — **never** teleported/culled at the close instant. `occupancyOf(shopId)` falls
|
||
to 0 within one dwell window on its own, so browser rigs (the C→D→F seam) clear with the same drain.
|
||
✓ your `patronTimer`/`_emerge` already do this — do NOT add a hard close-time eviction.
|
||
3. **Player is never force-ejected** — closing locks *entry* only; a player inside stays until they leave.
|
||
4. **The openLate video shop** is the one place still filling after ~21:00; at its own close it drains
|
||
identically. All seeded + clock-driven ⇒ deterministic occupancy.
|
||
No code change requested of you — this pins the behavior so it can't drift, and documents it for v3.
|
||
|
||
### → everyone / F: the CityPlan v2 producer contract is FROZEN
|
||
|
||
CITY_SPEC now has a **"CityPlan v2 — frozen producer contract"** section: producer API, the goldens
|
||
table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`), the town-key
|
||
mechanism, osm normalization rules, and the hours/openLate/storeys/closing laws — all matching code
|
||
(F: diff away). Changes to Layer-1 now require a CITY_SPEC amendment + golden re-pin in the same commit.
|
||
|
||
## Round 8 (2026-07-15)
|
||
|
||
### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg.
|
||
|
||
- **Selfcheck parity done**: the full invariant suite (`structuralSuite`) now runs on **every
|
||
(source, town)** — synthetic and both OSM towns get identical coverage (storeys, one-shop-per-lot,
|
||
facing, within/cross-block overlap, corridor coverage, determinism, exactly-one-openLate, …).
|
||
`node selfcheck.js` → **ALL GREEN 1727/1727**.
|
||
- **Selector gained a town arg** (`web/js/citygen/index.js`):
|
||
`generatePlanFor(seed, source, { town })`. Shell wiring: read `&town`, pass it through —
|
||
`generatePlanFor(seed, src, { town: q.get('town') || undefined })`. `map.html` is the reference
|
||
impl (`?plansrc=osm&town=katoomba`). Unknown/absent town → the default (`melbourne`).
|
||
`osmTownKeys()` lists the available towns for a UI picker.
|
||
- **THREE goldens to pin** (seed 20261990), all asserted by selfcheck already:
|
||
| source / town | golden |
|
||
|--------------------|--------------|
|
||
| synthetic | `0x3fa36874` |
|
||
| osm / melbourne | `0x34cfdec0` |
|
||
| **osm / katoomba** | `0x0f652510` |
|
||
The determinism gate should key on **(seed, plansrc, town)**. Synthetic + melbourne goldens are
|
||
**unchanged** from round 6.
|
||
- Katoomba (Blue Mountains, 19 shops — op-shop/book heavy) is a deliberate small-regional contrast
|
||
to inner-Melbourne (95, book heavy). Both boot end-to-end; screenshots in `docs/shots/laneA/`.
|
||
|
||
### Normalization: the importer bends the DATA to the contract, never the contract (round-8 item 4)
|
||
|
||
Real OSM data that fights a CityPlan contract is normalized deterministically, and the change is
|
||
**logged** (selfcheck prints a per-town line; `generatePlanOSM(seed, town, { report })` fills `report`):
|
||
- unknown OSM `shop=` kind → `opshop` (`typesRemapped`);
|
||
- a shop closing ≥22:00 → clamped to 21:00 so only the one openLate landmark is late (`hoursClamped`);
|
||
- openLate landmark prefers a **video**; a town with no video falls back to another non-stall type
|
||
and `report.openLate` records it (both Melbourne + Katoomba have video, so both read `video`).
|
||
|
||
### RECIPE — add another OSM town (mechanical, ~10 min)
|
||
|
||
1. **Pick a cluster** in thriftgod's `city_source.json` (Overpass cache). Find a centroid + a cell
|
||
size that captures one coherent town (aim 15–150 on-theme shops). The extraction one-liner used
|
||
for melbourne/katoomba is in `A-progress.md` (round 6/8) — reuse it with your `(cLat,cLon,cell)`.
|
||
2. **Append the town** to `OSM_TOWNS` in `web/js/citygen/osm_fixture.js`:
|
||
`key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }` — sort shops
|
||
by `id` (determinism). Map OSM `shop=` kinds → registry types (charity→opshop, books→book,
|
||
music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn).
|
||
3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`. The unpinned town prints a ready-to-paste
|
||
line — `⚠ osm/<key>: UNPINNED — add to OSM_GOLDENS → <key>: 0x…,` — paste it into `OSM_GOLDENS`
|
||
in `selfcheck.js`, re-run → green. (The full parity suite already ran on the town; only the golden
|
||
needed pinning.)
|
||
4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the
|
||
full parity suite covers the town automatically.
|
||
|
||
*Dry-run (round 9): verified end-to-end with a throwaway Newtown (42 shops) — the parity suite auto-ran
|
||
(1931/1932, only the golden unpinned) and selfcheck printed `newtown: 0x9045e577,`. Reverted; no 3rd
|
||
town shipped (didn't fall out "for free"). The recipe is mechanical as written.*
|
||
|
||
## Round 6 (2026-07-14)
|
||
|
||
### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin
|
||
|
||
The second plan producer is live behind the same CityPlan contract. Full seam, invariants green
|
||
(not a partial). What F needs to wire the shell bootstrap + extend the determinism gate:
|
||
|
||
- **Selector API** (`web/js/citygen/index.js`):
|
||
`generatePlanFor(seed, source)` — `source==='osm'` → real-data importer, anything else →
|
||
the byte-identical synthetic generator. Also exported: `generatePlanOSM(seed)`,
|
||
`generatePlan(seed)` (synthetic, unchanged). Unknown source falls back to synthetic.
|
||
- **Shell wiring** (in `web/index.html`, F-owned — one line): read `?plansrc`, then
|
||
`const src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';`
|
||
and call `citygen.generatePlanFor(seed, src)` where it currently calls `generatePlan(seed)`.
|
||
`map.html` already does exactly this (reference impl).
|
||
- **Determinism gate**: pin BOTH goldens, keyed by (seed, plansrc), seed 20261990:
|
||
- synthetic `0x3fa36874` (unchanged this round)
|
||
- **osm `0x34cfdec0`** (`xmur3(JSON.stringify(generatePlanOSM(20261990)))`)
|
||
`selfcheck.js` already asserts both; the smoke can just hash-compare.
|
||
- **Zero network**: the fixture is `web/js/citygen/osm_fixture.js` (95 real inner-Melbourne
|
||
shops from thriftgod's Overpass cache), imported as a JS module — no fetch/XHR at runtime.
|
||
|
||
**⚠ OSM plans differ from synthetic in shape (by design — real towns aren't the synthetic template):**
|
||
- `plan.source === 'osm'` is set; `plan.size` is the real bounding box (~362×486), **not** 1024².
|
||
- **No synthetic districts/types**: an OSM town has NO market square, arcade, dept anchor, milk
|
||
bars, or stalls — only the real retail types (record/opshop/toy/book/video/pawn). Any code that
|
||
ASSUMES a dept/market/milkbar exists must guard on `plan.source !== 'osm'` (or just not assume).
|
||
- Everything B–F actually consume is identical: nodes/edges/blocks/lots/shops schema, `ry`/
|
||
`frontEdge` semantics, `chunkIndex`, hours + the exactly-one-openLate-video rule all hold.
|
||
- Shop `name`s are the REAL OSM names (e.g. "Central Catholic Bookshop"); `sign` is derived.
|
||
Parody-transform of names is a documented deferred nicety, not a blocker.
|
||
|
||
## Round 4 (2026-07-14)
|
||
|
||
### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives
|
||
|
||
`tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round:
|
||
- `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4).
|
||
- **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the
|
||
checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the
|
||
scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓
|
||
|
||
### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video)
|
||
|
||
Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **4–15 shops/seed**, not one:
|
||
regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract
|
||
(`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the
|
||
openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it:
|
||
|
||
- **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen
|
||
`openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall
|
||
only if a town somehow has no video — never happens in practice).
|
||
- **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate`
|
||
shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the
|
||
same singleton — **gate on either**, they agree.
|
||
- selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video).
|
||
|
||
**⚠ GOLDEN.hash changed: `0x098eec2b` → `0x3fa36874`.** This is the *intended* consequence of the A2
|
||
plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not**
|
||
drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js
|
||
carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins).
|
||
|
||
## Round 3 (2026-07-14)
|
||
|
||
### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed)
|
||
|
||
Your consistency gate prints ~5 warnings per run:
|
||
`"N shop(s) have storeys outside their registry type range … else a plan↔registry drift — ask Lane A"`.
|
||
|
||
**Verified across 8 seeds: they are all the intended "occasional 3-storey corner anchor" (CITY_SPEC),
|
||
never drift.** Evidence:
|
||
- Single-storey types (`video`/`milkbar`/`stall`, registry `[1,1]`) are observed at 1 storey ONLY —
|
||
e.g. 412 video shops, none at 2. So the specific "video at 2" example does **not** occur.
|
||
- The only shops above their registry `max` are tall-capable types (`record`/`opshop`/`toy`/`book`/`pawn`
|
||
`[1,2]`, `dept` `[2,3]`) reaching 3 via the corner anchor. Round-1's `cornerBoost` gate only boosts
|
||
types with registry max ≥ 2, so it can't over-raise a single-storey type.
|
||
|
||
**To silence the warning correctly**, scope your check to the *permitted* max, not the strict registry max:
|
||
```
|
||
permittedMax(type) = registryMax >= 2 ? Math.min(registryMax + 1, 3) : registryMax
|
||
// flag only shops with storeys < registryMin || storeys > permittedMax → that IS real drift
|
||
```
|
||
This rule is now documented in `docs/CITY_SPEC.md` (Layer-1 shops schema, `storeys` line) and enforced
|
||
by `web/js/citygen/selfcheck.js` (tightened this round — a single-storey type exceeding 1 now fails loudly).
|
||
|
||
### → Lane F: `hours` / `openLate` contract is ready for your §3.5 wiring (no changes)
|
||
|
||
`hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. **Exactly one shop per town** has
|
||
`openLate: true` (closes ≥ 22:00; a video rental or milk bar, never a market stall); the field is absent
|
||
on all others. Gate on the field — `plan.shops.find(s => s.openLate)` — not a magic hours threshold.
|
||
Your ROUND3_INSTRUCTIONS §Lane F states it correctly, so consume as-is.
|