diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 9fec90d..faf3038 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,71 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item) + +*PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path, +so the R18-style before/after is the deliverable. `selfcheck` **ALL GREEN 161300/161300**, `0x3fa36874` +frozen (runtime-only change — no plan, no golden).* + +### The finding grew while it waited: 8.7 % → 42.6 % +I filed this at 8.7 % in R20, measured at katoomba's isolated pub. **A's R21 cluster bias made it worse**: +venues now sit *inside* the retail cluster, where peds converge from every side rather than trickling along +one street — so far more in-range peds are across a chunk edge. Re-measured on the relocated venue: +**42.6 %** of in-gig-range ped-samples (5094 of 11971) were chunk-blind. Fixing it mattered more in R23 +than when I filed it. + +### The override: the brief contradicts itself, and the measurement broke the tie +The brief said *"extend `_nearestOpenShop` to the ped's chunk + 8 neighbours"* **and** *"byte-identical +flags-off"*. **I built the literal version first and measured it: those two cannot both be true.** On the +synthetic town (exactly what `?classic=1` boots, gig layer absent), a full 3×3 scan sourced **127 of 855 +finds — 14.9 % — from neighbour chunks**: finds v2 never made. The frozen v2 crowd would start shopping +~15 % more. That is a covenant breach, in a clause of the same brief that asked for it. + +**So the fix is scoped to the GIG path** — which is also what I actually filed (the finding was always +`GIG_RANGE`-specific): +- **Ordinary shops → own chunk only.** v2 semantics, frozen. A shopfront is noticed from its own block. +- **Gig venues → the full 34 m radius, honest across chunk edges.** The gig is the "follow the sound" pull + that is *supposed* to reach further — that is the entire reason `GIG_RANGE` (34) > `PATRON_RANGE` (18). +- **No gig on → `_gigVenues` is empty → the neighbour loop is skipped entirely.** `?classic` and every + flags-off boot take the *identical code path and identical cost* to v2. **Byte-identical by construction**, + not by measurement luck. + +Both ranges are < `CHUNK` (64), so a 3×3 sweep is *exact*, not an approximation; a fixed `dz/dx` order keeps +exact-distance ties deterministic, as the old single-list scan was. + +### Before / after +| proof | before | after | +|---|---|---| +| **cross-chunk in-range peds that see the gig** — katoomba | **0 % by construction**¹ | **4/4 = 100 %** | +| — fitzroy (densest, worst case) | **0 % by construction**¹ | **11/11 = 100 %** | +| same-chunk peds (must not change) | 100 % | 100 % | +| **classic / flags-off: finds sourced from a neighbour** | 0 (v2) | **0** (127 under the rejected full scan) | +| determinism (2 sims, same seed) | identical | **identical** (identity sets byte-equal) | +| NaN — katoomba / fitzroy / synthetic | 0 | **0** | +| sim cost, katoomba @14:00, 236 peds | 0.056 ms/frame | **0.057 ms/frame (+2 %)** | +| lookup cost/call — no gig on | 0.58 µs | **0.58 µs (loop skipped)** | +| lookup cost/call — gig on | 0.58 µs | **1.3–2.7 µs** | + +¹ *not a small-sample claim: v2 read `shopsByChunk.get(ownChunk)` only, so a venue in another chunk was +never in the scanned list and could not be returned. 0 % is structural.* + +The lookup is 2–4× dearer **while a gig is on**, but it only fires every `PATRON_STRIDE` (10 m walked), not +per frame — hence **+2 % of a 0.056 ms frame cost**. Against a 16.7 ms budget that is 0.006 %. Fitzroy at 376 +peds stayed clean. Minor known inefficiency: with a gig *scheduled but closed* (e.g. 14:00) the neighbour +loop still runs for nothing (0.58 → 1.3 µs) — `_gigVenues` holds ids, so testing openness would need the +lookup it's trying to avoid. Not worth it at +2 %; noted rather than hidden. + +### Handshakes +- **D → F (gate):** the fix is **runtime-only** — no plan, no golden, no flag, no fetch. `?classic=1` takes + the identical code path to v2 (loop skipped), so the classic regression is untouched **by construction**. + Selfcheck green at 161300. No D-side blocker for `v5.0-alpha`. +- **Carried (deliberate, documented):** `PATRON_RANGE` keeps v2's own chunk-blindness — an ordinary shop 10 m + away across a chunk edge still goes unnoticed. That is now a *frozen v2 quirk*, not an oversight: fixing it + costs the classic covenant, and the covenant wins. Revisit only if the covenant is ever relaxed. +- **Still unowned (unchanged):** R20's main-street relocation half (heroes 0–19 % of the densest cluster). + +--- + ## ROUND 22 — v4.0 THE EPOCH CLOSE: citizens at pack scale (ledger #5) *PROCITY-D, 2026-07-16. The fitzroy stress audit (160 shops — the densest citizen field this epoch) + the diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 5af44cc..9c1f398 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -375,20 +375,53 @@ export class CitizenSim { return base; } - // nearest OPEN shop the ped is currently passing (current chunk, ≤PATRON_RANGE), else null. - // Hours-aware — at night only the openLate video shop qualifies, so its block draws the night crowd. + // nearest OPEN shop the ped is currently passing, else null. Hours-aware — at night only the openLate + // video shop qualifies, so its block draws the night crowd. + // + // [R23] The carried v4.x fix — GIG_RANGE (34 m) is a RADIUS, but this read only the ped's own 64 m chunk, + // so a gig venue genuinely in range, just across a chunk edge, was invisible. A's R21 cluster bias made + // that WORSE, not better: venues now sit INSIDE the retail cluster where peds converge from every side, + // so 42.6% of in-gig-range peds were chunk-blind (up from the 8.7% I filed at the old isolated pub). + // + // The fix is scoped to the GIG path on purpose. Extending the neighbour scan to ORDINARY shops as well + // was the literal brief, but measured it breaks the same brief's "byte-identical flags-off" clause: on + // the synthetic town (what ?classic=1 boots) 127 of 855 finds — 14.9% — would newly come from a + // neighbour chunk, i.e. the frozen v2 crowd would start shopping ~15% more. So: + // · ORDINARY shops: own chunk only — v2 semantics, frozen. A shopfront is noticed from its own block. + // · GIG venues: the full 34 m radius, honest across chunk edges — the gig is the "follow the sound" + // pull that is SUPPOSED to reach further; that is the whole point of GIG_RANGE > PATRON_RANGE. + // With no gig on, `_gigVenues` is empty and the neighbour loop is skipped entirely ⇒ ?classic and every + // flags-off boot take the identical code path AND identical cost to v2. Byte-identical by construction. + // Both ranges are < CHUNK (64), so a 3×3 sweep is exact, not an approximation. Fixed dz/dx order ⇒ + // exact-distance ties resolve deterministically, as the old single-list scan did. + // Carried: PATRON_RANGE keeps v2's own chunk-blindness. Revisit only if the covenant is ever relaxed. _nearestOpenShop(c) { if (!this.shopsByChunk) return null; - const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z)); - if (!list || !list.length) return null; + const cx = chunkCoord(c.x), cz = chunkCoord(c.z); let best = null, bd = PATRON_RANGE; let gigBest = null, gbd = GIG_RANGE; // R13: the nearest gig venue in range — it wins over ordinary - for (const s of list) { // shops (follow the sound), and across concurrent venues. + const own = this.shopsByChunk.get(chunkKey(cx, cz)); // shops (follow the sound), and across venues. + if (own) for (const s of own) { if (!this._openAt(s.hours)) continue; const d = Math.hypot(s.x - c.x, s.z - c.z); if (this._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; } if (d < bd) { bd = d; best = s; } } + if (this._gigVenues.size) { // gig on → the 34 m pull reaches across chunk edges (gigs only) + for (let dz = -1; dz <= 1; dz++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dz === 0) continue; // own chunk already scanned above + const list = this.shopsByChunk.get(chunkKey(cx + dx, cz + dz)); + if (!list) continue; + for (const s of list) { + if (!this._gigVenues.has(s.shopId)) continue; // neighbours contribute gig venues ONLY + if (!this._openAt(s.hours)) continue; + const d = Math.hypot(s.x - c.x, s.z - c.z); + if (d < gbd) { gbd = d; gigBest = s; } + } + } + } + } return gigBest || best; } _beginVisit(c, shop) {